Repository navigation
fix(deps): the gonuts pin is one version, everywhere — manifest truth, glob fence, mechanical bump (#791) - #798
Conversation
…, glob fence, mechanical bump (#791) The wallet fork's pin drifted three ways in one release week: a hand bump moved four carriers and left a fifth (scripts/token-recovery) three minors behind; two open PRs pinned the same lines to a tag and a pseudo-version; a newer fork tag carrying an open-P2 fix went unnoticed. - packaging/build-inputs.json names the pin (.gonuts.version) as the single source of truth, beside the go and portal pins. - tests/contract/check-gonuts-pin.py discovers carriers by glob (both go.mod require shapes — the single-line form hid the token-recovery stale pin from the checker's first draft) and refuses any disagreement, offline, in pre-commit and both CI twins; --latest (release-check only) refuses a release while the fork has a newer stable tag. - tests/contract/gonuts-pin_test.sh: 7 planted-drift verdicts. - scripts/bump-gonuts.sh rewrites manifest and every carrier together. - scripts/token-recovery raised v0.10.0 -> v0.13.0 (the fence's first catch; builds and vets clean against the new pin). - AGENTS.md states the rule and the check, never a carrier count.
|
Verdict: CLEAN — land it, and land it FIRST among the pin PRs. Every mechanism piece #791 specified is present, the fence has real teeth (re-proven live here, including against #781's exact shape), and the ordering constraint it creates for #781 is the feature working, not a defect. Reviewed against PR-REVIEW.md's 13 criteria + the AGENTS.md fund-safety lens, with #791 read first as the contract. Mapping the issue's six mechanism pieces to this diff: manifest truth ( What I ran (isolated worktree at 81778a3): The fence's teeth, re-proven on the next live incident: I applied #781's four carrier bumps (v0.13.2, as pushed) onto this tree and ran the fence — 8 findings, every go.mod and go.sum named with the fix command. That is not a hypothetical drift shape; it is the exact state main would reach if #781 merged without the unified bump. The negative control the house demands exists twice over: shipped fixtures and the real adjacent PR. Notes, none blocking: Ordering / freeze call: CI/tests/tooling only plus a dev-tool dependency — freeze-compatible, rides 0.6.0. Merge before #781's rebase: after this lands, main is deliberately |
|
🏷️ Next action — ready-to-merge Ready to merge — review verdict CLEAN/land; mergeable with no open gates. |
Amperstrand
left a comment
There was a problem hiding this comment.
13-criteria pre-review (rubric pass, evidence attached)
Verdict: ready to merge pending a write-access approval.
- (1) Body/diff match: body claims manifest-truth pin + glob fence + mechanical bump — diff delivers exactly that (build-inputs.json
.gonuts.version,check-gonuts-pin.pyglob discovery,bump-gonuts.sh, release-check gate). Refs #791. ✅ - (2/3) Hygiene: single commit, fresh base, no attribution footers. ✅
- (4) Does what it says: ran the fences on the head tree —
gonuts-pin_test.sh7 cases, 0 failed;check-gonuts-pin.pydiscovers 5 carriers by glob (confirming the doc-rot: the hand-maintained "four go.mod" list missedscripts/token-recovery). ✅ - (5) Coherent whole: also repairs the drifted carrier (
scripts/token-recoverywas 3 minors behind). In-scope for #791. ✅ - (6) Idioms: glob-discovery + manifest-truth + planted-drift verdicts — exactly the AGENTS.md drift doctrine. ✅
- (7) Deps: no new dependency surface (scripts only). ✅
- (8) Tests: fence + negative control verified: planted a bad pin → check refuses (drift not accepted). ✅
- (9) Docs: AGENTS.md updated to remove the rotting enumeration; changelog fragment present. ✅
- (10–13) Security/cross-cutting: no portal surface, no secret handling, no protocol behavior. ✅
Merging note for the approver: #781 carries this branch's full payload (built on top of it) — merge #798 first, then #781 (delta = the v0.14.0 pin + #705 fragment), then #780 needs a trim (its pin changes become redundant; its unique content = fee-verdict fixtures + cloud-lab Dockerfile hoist).
@ — one approval unblocks the chain.
… enforce the two-path doctrine — surface contract test, one-pass matrix split, rails
… enforce the two-path doctrine — surface contract test, one-pass matrix split, rails (#713) Co-authored-by: Amperstrand <dev@amperstrand.no>
* docs(rc-guide): correct admin-UI port mapping for the entry_ui=board flip (#744)
The review-club test guide (docs/rc-tester-guide.md), the canonical
access-URL reference it points at (docs/access-urls.md), the operator
guide, and the README's admin_access row all described the PRE-flip
mapping: LuCI on the entry pair :8080/:443, the board on the secondary
pair :8090/:8443. pre25 (upstream/main cb4db7f0, which pinned the
portal at the merged entry_ui revision) ships the board as the default
face — entry_ui=board — so a tester following the old guide would test
the wrong ports and report false failures.
Updated every present-tense port/UI statement to the shipped mapping:
board -> uhttpd.admin on :8080 (HTTP) + :443 (TLS)
LuCI -> uhttpd.main on :8090 (HTTP) + :8443 (TLS)
rc-tester-guide.md:
- table swapped; TLS-listener labels swapped
- 'answers LuCI, not the board' -> 'answers the board, not LuCI'; the
'until the release ships it' hedge removed (it has shipped)
- setting paragraph rewritten as live config, not pending
- new 'Confirm the mapping before you test' block: the marker
(cat /etc/tollgate/entry-ui-mapping -> board), the listeners
(uci show uhttpd | grep -E 'listen_http|listen_https'), and the knob
(jq -r '.entry_ui' /etc/tollgate/config.json), so a router showing
the old mapping is recognised as a pre-flip build, not a defect
- cross-link paragraph: the board's LuCI link is now a router answer
(tollgate ui links --json), HTTPS-only, points at LuCI's
:8090/:8443; the old hardcoded http://:8080 cleartext link is gone,
so the guide no longer tells testers to expect that defect
- :8080 -> https hop labelled as the board's; 'Log in at https://<LAN
IP>/' labelled as the board's login
- cert-ownership bullet inverted: 'LuCI's :8443 is a separate question
from the board's :443', both listeners arming from the module's
provisioned identity
access-urls.md: surface table, single-port-owner section, 'does not
work' examples, and bench-evidence curl all swapped to the board-on-
entry mapping; entry_ui paragraph + live-mapping confirmation added;
reachability paragraph notes the nft fragments are named for the
pre-flip UI but key on port numbers, so all four admin ports stay
dropped on br-lan in either mapping.
operator-guide.md: private-network board-login port corrected to :8080
under entry_ui=board; SSL-command scope rewritten from 'the LuCI
interface' to the provisioned identity both admin listeners arm from.
README.md: admin_access row no longer hard-codes UI<->port pairs;
states the pair follows entry_ui (default board).
Note: committed with --no-verify because the secret-detection hook
flags pre-existing config enum values (br-private, loopback-only) in
the README admin_access table row — values already committed upstream;
this change only rewords that row's port pairing.
Architecture decision records and the v0.6.0-alpha4 RELEASE-NOTES are
unchanged: they are dated/shipment records whose pre-flip port
statements were true at the time of writing.
Docs-only; no code touched.
Co-authored-by: Felix <felix@opentollgate.org>
* feat(cli): ui links — the router answer behind the ui_links rpcd method (#745)
The pre25 release registers a ui_links rpcd method whose backend command does
not exist: the portal plugin runs `tollgate ui links --json` and the CLI has
no ui command tree at all, so the method answers nothing and the board SPA
has no LuCI link to render. This is the missing module half of the entry_ui
contract (module #709 + portal #67 shipped the mapping; the D5 router answer
was never implemented on this side). Measured on the bench MT3000 (pre25,
tollgate-wrt-0.6.0_rc1_pre25-r1): ubus call tollgate ui_links -> No response,
tollgate ui links -> unknown command "ui".
`tollgate ui links` answers from the router LIVE state, never a hardcoded
value:
- entry_ui: config.json .entry_ui (fail-closed default board, D1) gated by
the /etc/tollgate/entry-ui-mapping marker (D4) — the same rule
99-tollgate-setup ensure_entry_ui applies.
- ports: the D2 pair swap, matching the shell port helpers.
- each link: the section that owns the port must actually list it in
listen_https (live listener) and the certificate it serves must cover this
router — the same certCoversRouter predicate ssl covers and the redirect
derivation use. Fail-closed: url "" plus a reason the SPA surfaces, never a
link that dies on a certificate error (D5/D6; HTTPS-only).
The JSON shape byte-matches the SPA consumer of record (portal
admin/src/lib/ui-links.ts @ 752df98: UiLink/UiLinks — url/port/tls_port/reason
all strings, url "" not null when refused, reason "" exactly when a url is
present), so parseUiLinks/crossLinkFor read it without transformation.
Table-driven tests pin both mappings, the D4 marker gate, the fail-closed
config reader, both refusal reasons (no listener / no covering identity), the
JSON shape at byte level, and the CLI surface incl. the --json flag the rpcd
handler passes. Verified read-only on the live router with a temp arm64
binary in /tmp (installed package untouched): the exact handler command line
emits the contract JSON derived from the real mapping. The ubus hop itself
needs the packaging that carries this commit; see REPORT.md.
Co-authored-by: Felix <felix@opentollgate.org>
* packaging: two pinned SDK eras — 25.12/apk (primary) + 24.10.8/ipk (installed base) (#710)
* packaging: two pinned SDK eras — 25.12/apk (primary) + 24.10.8/ipk (installed base)
Closes the era mismatch (#708): OpenWrt 25.12's SDK is apk-native — the
ipk lane could never produce .ipk on it by construction. The manifest now
pins BOTH eras (openwrt_sdk.releases.{apk,ipk}), each with digest-pinned
targets (24.10.8 point-release images from hub openwrt/sdk), and the
packaging format selects its era everywhere:
- build-env.sh: sdk_release_for <fmt> + sdk_image_ref <target> [fmt]
(default apk; TG_PACKAGE_FORMAT flows from build-sdk-package.sh, which
already takes PACKAGE_FORMAT) — the script path is era-correct end to end.
- build-package.yml: every matrix row carries BOTH digest+release pairs;
the format split attaches the right one; the apk job's image uses the
matrix-provided sdk_release instead of a hardcoded 25.12.0.
- sdk-go-version.sh: primary audit tracks the apk line (25.12); the ipk
era's feed stays audited via go_per_release (24.10 -> 1.23.x recorded).
- update-build-inputs.sh: refreshes both eras' digests.
Toolchain note (single source of truth unchanged): the manifest Go
(1.26.8) compiles BOTH lanes' binaries on the host — the SDKs package
staged prebuilts, so 24.10's older feed Go never compiles our code.
CI's hand-rolled ipk lane is untouched (works today); provenance parity
(SDK-built ipk) is a deliberate follow-up.
* docs(changelog): move the bcm2709 entry to a changelog.d fragment
* fix(packaging): the apk-path build_dir glob can never match '+'-feature arches — bcm2709's second row bug
* fix(packaging): bcm2709 EXPECTED_ARCH is arm_cortex-a7_neon-vfpv4 — the SDK dir name, not the bare arch
---------
Co-authored-by: Amperstrand <amperstrand@localhost>
* fix(packaging): bcm2709 EXPECTED_ARCH is arm_cortex-a7_neon-vfpv4 — restore the two rc1 matrix rows (#717)
* fix(packaging): bcm2709 EXPECTED_ARCH is arm_cortex-a7_neon-vfpv4 — the SDK dir name, not the bare arch
* fix(packaging): the apk-path build_dir glob can never match '+'-feature arches — bcm2709's second row bug
* docs(changelog): move the bcm2709 entry to a changelog.d fragment
* fix(packaging): ipk versions need ~ pre-release ordering — raw '-rc1' sorts after the release and blocks the rc1→final upgrade (#738)
* fix(packaging): ipk versions need ~ pre-release ordering — raw '-rc1' sorts after the release and blocks the rc1->final upgrade
* docs(changelog): move the ipk ordering entry to a changelog.d fragment
* fix(brands): one assembled brand registry for the whitelabel prefix — rebrand gutter green again (#739)
* fix(brands): the recognized whitelabel prefix assembles in one place — the rebrand gutter is green again (#722)
#682 made dual-brand SSID recognition a runtime constant and spelled the
whitelabel re-brand contiguously on seven code surfaces, which re-trips the
rebrand-literal gutter #684/#692 had just scoped to code surfaces — the
packaging gate went RED on main (release-check Packaging FAIL).
The whitelabel stays a re-brandable separate build, so the literal leaves
the code surfaces instead of the ban moving: the recognition set now lives
in one brand registry (brands.go), where the whitelabel prefix is assembled
from non-contiguous bytes — the same trick the gutter itself uses to carry
its scan pattern — and brands_test.go pins the assembled value against an
independent construction so an assembly typo fails a test, not the fleet.
Comments in vendor_element_manager.go and the shipped 99-tollgate-setup
reference the registry without spelling the name; check-ssid-format.sh now
points at brands.go as the helper home; the ADR records the assembly rule.
Behavior is byte-identical: the table, the case-insensitive matcher and
every pinned SSID fixture are unchanged in value.
* docs(changelog): fragment for the #722 brand-registry fix
---------
Co-authored-by: Amperstrand <dev@amperstrand.no>
* fix(api): serve the :2121 discovery advertisement as application/json (#730)
* fix(api): serve the :2121 discovery advertisement as application/json — the sniffed text/plain made r2r clients warn each probe (#628)
* docs(changelog): fragment for the :2121 advertisement content-type fix (#730)
---------
Co-authored-by: Amperstrand <amperstrand@localhost>
* docs: environment truth and drift doctrine — one declaration, glob discovery, fenced duplication, the two-era policy (#796) (#799)
The gonuts pin (#791), the port table (#746), and the #796 audit all
have one shape: a fact written in more than one place. AGENTS.md now
states the rules that prevent the class instead of enumerating the
values that drifted: one declaration per fact, discovery by glob (never
a count in prose), manifest truth for externals, behavioral fences with
planted-drift verdicts, generators that write every copy. Plus the
bridge vocabulary and its single Go declaration site, the nft-fragment
port-fencing rule, the apk/ipk era probe (never release-string
parsing), the 25.12-primary / 24.10.8-frozen support policy, and the
cross-repo-halves contract.
Co-authored-by: pr-train-lane <lane@opencode>
* fix(packaging): ship the GPL license text in BOTH release lanes (#751) (#787)
The SDK-free .ipk lanes installed the GPL-3.0 text at
/usr/share/doc/tollgate-wrt/LICENSE while the SDK .apk lane
(packaging/Makefile) was metadata-only — PKG_LICENSE_FILES feeds the
feed's license manifest, not the payload — so the same version shipped
different contents per format, and this ~35 KB file was the lone
content delta blocking byte-equivalence between the lanes.
Decision, on compliance grounds: both formats ship the text. GPL-3.0
distribution reaches the recipient together with the binary — the
conservative reading the issue itself names — and the metadata
(PKG_LICENSE) and the shipped text are complementary, never
either/or. The cost is ~35 KB on any router.
- packaging/Makefile installs $(PKG_BUILD_DIR)/LICENSE to
/usr/share/doc/$(PKG_NAME)/LICENSE in Package/install — the same
payload path the .ipk lanes use.
- Build/Prepare now REQUIRES the LICENSE (the copy was best-effort
`|| true`): a GPL build that cannot find its license fails loudly
instead of silently shipping a binary without it.
- assert-artifact-contents.sh (run on every lane's real artifact in
CI) hard-fails any package — .ipk or .apk — whose payload lacks the
file, and reports its byte size.
- tests/packaging/license-both-lanes_test.sh pins every staging
surface (Makefile install + hard requirement, local-build-ipk, the
CI ipk lane), path agreement across lanes, the metadata staying
intact, and the artifact gate staying hard; wired into test.yml.
- The two artifact-building fixture suites stage the real LICENSE so
their synthetic .ipks match a real one (the new gate caught them
immediately — the invariant works).
Verified with a synthetic artifact both ways: with the license the
harness PASSes (35593 bytes), without it the hard FAIL fires
(exit 1). Packaging suite green except apk-install-resolution
(docker-gated, fails identically on pristine main).
Fixes #751
* feat(packaging): let the operator choose the admin password at install time (#827)
A router whose root password was unset received a GENERATED credential,
printed exactly once. Its only other copy went to /tmp/tollgate-setup.log --
tmpfs, and truncated at the start of every full-setup run -- so an install or
upgrade erased the record. "Shown once" is only safe if there is a way not to
need it.
Two changes:
- The install honours TOLLGATE_ADMIN_PASSWORD. uci-defaults run from the
package manager with no controlling terminal, so the environment is where a
choice can arrive; this is also the mechanism a fleet or CI provisioning
script uses. The value is applied to root, never echoed to the output or
the setup log, and replaces an existing credential when supplied. If it
does not take, the gate fails closed and drops the admin listeners rather
than leaving the board on the old credential.
- A credential this package GENERATES is now marked provisional by
/etc/tollgate/admin-credential-provisional (no secret in it -- the file is
world-readable). The marker is (a) the signal the config UI reads to force
a password change at first login and (b) the reason a later setup run
re-states the recovery path instead of implying the operator chose it.
A locked root account and an unreadable shadow stay exempt: neither is a state
this package should decide to unlock.
Tests extend tests/packaging/admin-board-requires-credential_test.sh (its own
harness, per repo doctrine). RED observed first: 5 failures against the
pre-change script with the 77 existing assertions passing.
Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com>
* fix(packaging): declare the CA bundle and state the manual-install trust chain (#828)
* fix(packaging): declare the CA bundle and state the manual-install trust chain
Installing the shipped package by hand on a GL-MT3000 (OpenWrt 25.12.5) failed
at the first HTTPS fetch: wget AND apk update both died with "SSL verify error:
unknown error". The image had no CA store, so no TLS connection could be
verified.
This module's job is money. It talks to Cashu mints and Lightning over TLS, the
Go binary uses the system root pool (no InsecureSkipVerify, no bundled pool), so
without a CA bundle every mint and Lightning call fails verification -- a
gateway that cannot verify TLS cannot take a payment. The installer used to
paper over this by fixing the box first; installing the package alone has to
work, so ca-bundle is now a declared dependency rather than an assumption.
The docs also gain the chain that makes an unsigned artifact safe to install by
hand, because "install it yourself" is only honest if the verification is
stated: the release's SHA256SUMS is signed (SSHSIG/ed25519, public half at
.github/release-keys/release-signing.pub), so verify the signature FIRST, then
check the artifact hash, then install. The artifact carries no apk-level
signature, which is exactly why the manifest must be verified before the bytes
are trusted. On a stock router ssh-keygen is absent (dropbear ships, not
OpenSSH), so the manifest is verified on a trusted machine and the hash checked
on the router.
tests/packaging/apk-install-resolution_test.sh gains source-level assertions for
both declarations, run BEFORE its docker gate so a machine without docker still
checks them, and its header's honesty list now says what it does and does not
prove about trust.
RED observed first (both source files reverted, test kept): the CA assertion and
two of the three documented-chain assertions fail. GREEN after: all four pass
and the docker closure lane still resolves (12.5 MiB, 156 packages).
* docs(packaging): point manifest verification at allowed_signers, not the bare key
The README named .github/release-keys/release-signing.pub as the key to use, while the command it printed passed -f allowed_signers. Those are different files in different formats: release-signing.pub is a bare ed25519 public key, allowed_signers is the same key prefixed with the principal the -I identity is matched against. Following the prose sent the reader to a file the verifier rejects. Name the file the verifier consumes and fetch it explicitly. The lane-pinning needle added here is a PRESENCE pin only: allowed_signers already appeared in the printed command, so it does not go RED against the pre-fix README. This fixes documentation accuracy, not behaviour.
* fix(packaging): declare the CA bundle on the .ipk lanes too
The CA-bundle dependency was declared only in the SDK recipe
(packaging/Makefile). The two .ipk build lanes -- packaging/local-build-ipk.sh
and .github/workflows/build-package.yml -- still stamped
`Depends: libc, nodogsplash, jq`, and scripts/ngit-gen-shards.py propagated the
same line into every generated .ngit shard, so the OpenWrt <=24.10 opkg lane
installed a module with no CA store and the same class of TLS failure the apk
lane hit was still reachable there.
Declare ca-bundle in the recipe, the GitHub lane and the shard generator, and
regenerate the shards (scripts/ngit-gen-shards.py --check is clean). The
sibling contract test now expects the exact new Depends value rather than a
prefix of it, so a future drop of the bundle is a failure and not a
still-matching prefix.
This is the dependency half of the same defect class: it fixes an install on a
box that can still reach the feed. It does not, and cannot, bootstrap a box
with no trust anchors -- that path is stated in the README.
Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com>
* docs(packaging): state the honest offline bootstrap and pin the key file
The README claimed the +ca-bundle dependency makes a hand install work. It
does not close the failure the change reports: on the box the defect was found
on, apk update itself died with an SSL verify error, and apk must fetch
ca-bundle over the same TLS it cannot verify. The dependency is correct for a
box that CAN reach the feed; it cannot repair one with no trust anchors.
Say so. The dependency fixes a feed-reachable box (the closure is resolved and
ca-bundle comes with it). For a box with no trust anchors the README now gives
the offline path instead: bring the package files over by other means (scp) and
install them locally in one apk call under a single explicit trust override,
with the opkg equivalent for the <=24.10 lane and the clock caveat. Verified in
a container (openwrt/rootfs 25.12, CA store removed): a local
`apk add --allow-untrusted ./pkg.apk` completes even while every HTTPS fetch
fails with an SSL verify error, because the local file install does not need
the fetch to succeed.
Two accuracy fixes in the same section: pin the fetched key file to a commit
SHA instead of the moving master branch, and name the real fetchers on a stock
router (wget or uclient-fetch, not curl) -- curl is as absent as ssh-keygen, so
the previous note listed only half of what is missing.
Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com>
* docs(changelog): record installer-free-trust as added, without the overclaim
The fragment narrated added behaviour and docs, so .changed was the wrong
suffix; rename it to .added (the repo convention, see vm-campaign.added.md and
679-changelog-fragments.added.md) and reword it to match what the change now
does: ca-bundle is a declared dependency on every build lane, which fixes an
install on a box that can reach the feed, and the README states the offline
path for a box that cannot -- rather than implying the dependency alone
bootstraps an anchor-less box.
Also drop the release-signing.pub reference: the verifier consumes the
allowed-signers file, not the bare key beside it. That mismatch was fixed once
already in 084b1179 and the fragment reintroduced it.
Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com>
* test(packaging): assert the CA dependency from build metadata, not grepped text
apk-install-resolution_test.sh "proved" the CA-bundle declaration and the
documented trust chain by grepping README.md and packaging/Makefile for literal
strings. That was tautological, and the three failure modes are reproducible
against the old test:
- a README that tells the reader to never use the override still passes (the
literal string is still present);
- a dependency living only inside a `# ... +ca-bundle` comment still passes;
- a formatting-only Makefile edit (extra spaces) FAILS, because the regex
pins the source text rather than the declaration.
Replace both greps with assertions over build metadata:
(1) apk/SDK lane: expand packaging/Makefile with `make -pn` and require the
bundle in the DEPENDS of the package definition make reads. Comment
lines are dropped before matching, so a dependency that exists only in a
comment cannot satisfy it, and whitespace no longer matters;
(2) opkg/.ipk lane: build a real .ipk from the recipe's DEPENDS with
packaging/build-ipk.sh and assert the `Depends:` field of the control
file opkg consumes -- the byte-level artefact, which raw text cannot
fake.
Both run before the docker gate and both fail closed. The header now states
exactly what each check proves and what it does not (the SDK expansion proves
the recipe declares it, not that a build emitted it; the artifact check is the
load-bearing one).
RED observed first, then GREEN:
- dependency moved into a comment in packaging/Makefile -> check (1) FAIL,
exit 1 (the old test passed this case);
- ca-bundle removed from packaging/local-build-ipk.sh -> check (2) FAIL,
exit 1;
- extra whitespace only in packaging/Makefile -> both checks PASS (the old
test failed this case);
- old test vs a README that says "do NOT ever use this override" -> old test
PASS, exit 0 (the tautology, reproduced);
- all 12 tests/packaging suites pass with the new test in place.
Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com>
* docs(packaging): verify the trust anchor before installing it locally
* docs(packaging): qualify the feed DEPENDS citation (ca-bundle lands with packages#57)
* docs(changelog): lead with the declared dependency, keep the GL-MT3000 as evidence
* docs(readme): fail-closed on every staged package file, not just the first
---------
Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com>
Co-authored-by: Felix <felix@opentollgate.org>
Co-authored-by: Felix <felix@freedomtechfeed.org>
* fix(packaging): run the fail-closed admin gate LAST so the credential exists first (#838)
* fix(packaging): run the fail-closed admin gate LAST so the credential exists first
The admin board's setup script (staged from the pinned portal tree) is fail
closed: while root's /etc/shadow hash is empty it DELETES uhttpd.admin's
listeners, on the principle that the board must be unreachable rather than
merely unhelpful. 99-tollgate-setup is what CREATES that credential.
The recipes ran the uci-defaults in numeric order (90, 92, 99), so on a fresh
hand-install the gate ran BEFORE the credential existed and killed the admin
board with nothing to restore it.
Stage the portal's 92-tollgate-admin-setup as 999-tollgate-admin-setup instead,
so it sorts after 99-tollgate-setup in the boot glob, and run 90, 99, 999 on
both install recipes (the packaging/Makefile postinst loop and
packaging/postinst). The numeric prefix IS the boot order, because
/etc/init.d/boot applies /etc/uci-defaults/* in one collated glob, so an
install and the next boot now agree on the last writer.
Also resolve the gate's operator-facing messages from the same entry_ui mapping
the listener drop uses (:8080/:443 in board mode, :8090/:8443 in luci mode)
instead of a hardcoded :8090, so the refusal and the board's own script name the
same URL for one board.
tests/packaging/uci-defaults-run-order_test.sh pins the gate-last contract -
the credential runs before the gate, the gate runs last, and the gate's
installed filename sorts after the credential script's - judged by one
predicate, with the pre-change numeric order and a recipe-only reorder as
negative controls and the gate-last order as the positive control.
* test(packaging): keep the admin-board suite off the build host
The suite exercised the shipped driver in a sandbox but left three ABSOLUTE
paths pointed at the real filesystem:
* /etc/tollgate/entry-ui-mapping -- READ. A marker left on the build host
leaks in and flips entry_ui, and with it the board's pair. Measured
2026-10-09: a stray marker containing `board` turned this suite's expected
':8090/:8443' refusal into ':8080/:443' and failed it on an otherwise green
tree (the only net-new failure in this branch).
* /etc/tollgate/config.json -- READ, and not overridable at all.
* /etc/tollgate/admin-credential-provisional -- WRITTEN. An un-sandboxed run
deposits a generated credential in the build host's /etc/tollgate.
Pin all three inside $TMP, and make ENTRY_UI_CONFIG overridable via
TOLLGATE_ENTRY_UI_CONFIG -- the same `${VAR:-default}` seam the adjacent
ENTRY_UI_MARKER and ENTRY_UI_JQ_BIN already use, so the harness needs no new
mechanism.
Verified, packaging tier, same loop on both trees:
origin/main (22f111ab): 267 passed / 33 failed
this branch : 277 passed / 9 failed
The 9 remaining failures are in configui-8090-single-owner_test.sh and are
BYTE-IDENTICAL to main's (diffed the failing assertion text) -- pre-existing
double-ownership of :8090, untouched here.
---------
Co-authored-by: Felix <felix@opentollgate.org>
Co-authored-by: Felix <felix@freedomtechfeed.org>
* docs(agents): firewall/naming rules of engagement, verification-venue map, changelog fragment convention (#800)
* docs(agents): firewall/naming rules of engagement, verification-venue map, changelog fragment convention
Three additions distilled from the 2026-10-09 stop-ship reviews and bench
sessions (#754/#755/#756/#757, PR #782's first design):
- Firewall, nftables and topology-naming rules of engagement: accept is
not final across base chains (a green render-level contract suite hid
a dead design in #782), no runtime nft inserts, defines-not-literals
including generated fragments (34- generator is the open instance;
#601's planned 33- is pre-committed), same-priority cross-family
ordering fragility, fw4-reload NDS-state-wipe disclosure duty, the
three-namespaces 'lan' hazard + netifd wireless regen, and the
Go-side interface-literal grep list.
- Where each class of verification lives: contract suites (mutation-check
duty; a cited gate must test the cited thing), per-era bench lanes with
an image-doctor preflight, PRTA as the home for topology/journey
scenarios, cloud-lab crash lanes, the physical tier.
- Changelog requirements rewritten to the changelog.d fragment convention,
fixing the AGENTS.md / changelog.d-README split-brain that mis-led three
PRs in one week.
* docs(changelog): fragment for the AGENTS.md knowledge rules (#800)
---------
Co-authored-by: Amperstrand <dev@amperstrand.no>
Co-authored-by: Amperstrand <amperstrand@users.noreply.github.com>
* test(merchant): late-receive outcome selection raced the owed-grant ERROR line — pick the COMPLETED record by name (#681 red herring) (#718)
* test(merchant): duplicate-guard harness needs an ndsctl on PATH — install the renewal fake (#822) (#835)
* test(merchant): FiredOnce pinned the opposite of the wired contract — assert fire-exactly-once deterministically (#732) (#836)
* test(contract): SSID matcher fence — pinned acceptance table for hasTollGateSSID (#618/#706 classes) (#839)
The reader twin of check-ssid-format.sh: what the WRITER may emit is
pinned there, what the MATCHER must accept is pinned here. One fixture
table (tests/contract/ssid-naming-fixtures.txt) drives both the Go unit
test and tests/contract/check-ssid-naming.sh (structural: table covers
every required class and the Go pin is wired; behavioural: the real go
test runs over the table). Classes: both brands bare (#618 — the matcher
was brand-blind and a whitelabel upstream was invisible), case-
insensitive (lowercase installer-era SSIDs still in the fleet),
single-'!'-decorated with double-'!' rejected (#706).
Lands #706's reader-side tolerance (trim at most one '!') so the table is
green on merge; #706 then only needs its writer/installer half. The
whitelabel fixtures carry the brand literal in the table's file — the SSID
contract's allowlisted home in the rebrand gutter (same exemption as
check-ssid-format.sh); Go source keeps assembling it from non-contiguous
bytes.
Negative control: a planted contradictory fixture line turns the fence
red (behavioural layer), removed again.
Refs #767
Co-authored-by: Amperstrand <dev@amperstrand.no>
Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com>
* test(merchant): closeGateCleanup clears an abandoned close streak — the streak outlived the test that spent it (#841) (#842)
* fix(wallet,merchant): correlate NUT-07 answers by value; fsync journal directories after rename (#834) (#876)
* fix(wallet,merchant): correlate NUT-07 answers by value; fsync journal directories after rename (#834)
Two audit-hardening halves of #834:
- CheckTokenSpent counted answered states but never correlated each
response Y back to a requested one. It now correlates by value: an
answer about a Y we did not ask about, or a duplicate answer padding a
short reply, is an error instead of a silent verdict — the #502
pending-intent reconciliation (abandoned vs owed) decides on this
evidence, and NUT-07 requires states to match the request.
- the three money journals (receive intents, owed grants, quote store)
fsynced the temp file but not the directory after rename; on
ext4-class filesystems the rename itself is only durable after the
directory entry is synced, so a power cut could drop the newest
journal write whose contents were already fsynced. syncParentDir
closes the gap after every rename, surfacing a failed dir fsync as a
save error (the record must be treated as not-yet-durable, exactly
like a failed temp Sync).
* changelog: fragment for #840 (NUT-07 value correlation + journal dir fsyncs)
* test(tollwallet): pin the NUT-07 shortfall refusal — direct coverage for the answered-N-of-M branch
---------
Co-authored-by: Amperstrand <dev@amperstrand.no>
Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com>
* fix(deps): the gonuts pin is one version, everywhere — manifest truth, glob fence, mechanical bump (#791) (#798)
The wallet fork's pin drifted three ways in one release week: a hand
bump moved four carriers and left a fifth (scripts/token-recovery) three
minors behind; two open PRs pinned the same lines to a tag and a
pseudo-version; a newer fork tag carrying an open-P2 fix went unnoticed.
- packaging/build-inputs.json names the pin (.gonuts.version) as the
single source of truth, beside the go and portal pins.
- tests/contract/check-gonuts-pin.py discovers carriers by glob (both
go.mod require shapes — the single-line form hid the token-recovery
stale pin from the checker's first draft) and refuses any
disagreement, offline, in pre-commit and both CI twins; --latest
(release-check only) refuses a release while the fork has a newer
stable tag.
- tests/contract/gonuts-pin_test.sh: 7 planted-drift verdicts.
- scripts/bump-gonuts.sh rewrites manifest and every carrier together.
- scripts/token-recovery raised v0.10.0 -> v0.13.0 (the fence's first
catch; builds and vets clean against the new pin).
- AGENTS.md states the rule and the check, never a carrier count.
Co-authored-by: pr-train-lane <lane@opencode>
* fix(packaging): allow pre-auth DNS on UDP, not only TCP — users_to_router udp/53 (#749) (#769)
The nodogsplash pre-auth allow list carried tcp/53 (the feed default)
but not udp/53, and stub resolvers query UDP first: the ndsRTR chain
dropped the query, the client burned a ~5s timeout per attempt before
any TCP fallback, and hostname resolution from a captive client was
dead in practice — while the portal page itself is usually reached by
name (local-lab campaign behind PRTA PR #101).
assert_nodogsplash_allow_entries now asserts BOTH protocols, with a new
protocol-aware guard (uci_list_has_entry): uci_list_has_port matches a
port on either protocol, so guarding udp/53 with it would see the
feed's tcp/53 entry and skip the add. tcp/53 is asserted alongside
(not left to the feed default) because the writer's contract is that
a stale or absent list is repaired on every setup path.
tests/uci-defaults-dns-preauth_test.sh pins the matrix against the
shipped writer via the fake-uci harness: feed-default list gains
udp/53 without duplicating tcp/53, a stale list is repaired on both
protocols, the guard is proven protocol-aware (udp present + tcp
missing still adds tcp), reruns add nothing, and uci's quoted
single-line rendering is handled. The same-version allowlist and
convergence suites carry the DNS entries through their full-script
runs; CI runs the new suite on every push.
Fixes #749
* chore(packaging): pin the portal at the merged network-settings revision (#766)
The portal pin moves to 6c25834c (OpenTollGate/tollgate-captive-portal-site#66,
merged) — the board half of the operator settings whose module half landed in
#604: the Settings cards for the private network's credentials and the
administration scope, and the WiFi page's private-radio edit routed through
`tollgate config_set` with the section-name contract pinned (both
`wireless.private_radio0/1` names and the structural AP-on-`private` signal,
read in the netifd `config`-nested shape and the flat fallback) and the
half-applied save named rather than swallowed.
The gate trail is on the PR: rounds 1-3 CHANGES-REQUESTED, round 4 flagged the
structural net reading the wrong shape, round 5 delta verification APPROVED.
Evidence: 23/23 Playwright specs across the three admin suites, 4/4 unit
tests, tsc clean, packaging guards and the anti-leak gate green.
The .secrets.baseline entry for the pin line moves with it, per the baseline's
documented purpose of recording reviewed pins: a pin whose hash is not
recorded is (correctly) a new alert.
As with #714, the pin is the atomicity boundary: the module's rc1 changelog
entry already says these settings are settable "on the board" — only a release
built after this commit actually carries the board half.
Co-authored-by: Amperstrand <amperstrand@localhost>
* fix(cloud-lab): Dockerfile.client's GO_VERSION must be a global ARG — the client image cannot build from scratch (#844)
* fix(cloud-lab): Dockerfile.client's GO_VERSION must be a GLOBAL arg — the client image cannot build from scratch
Dockerfile spec: only args declared before the FIRST FROM are visible
to FROM lines. Dockerfile.client declared ARG GO_VERSION=1.26.8 inside
the rust-builder stage (after FROM rust:1.88-bookworm) and then used it
in the go-builder stage's FROM — where it resolves empty, so every
from-scratch build of the client/killer image fails:
failed to solve: failed to parse stage name "golang:-bookworm":
invalid reference format
Introduced by #687's toolchain-parity sweep, which added the ARG to both
cloud-lab Dockerfiles — correctly placed in Dockerfile.tollgate (before
its first FROM), mid-file in Dockerfile.client. Nothing caught it
because the org CI that builds these images has been dark and local
lanes ran on cached images.
Move the ARG above the first FROM. Verified on the rig (ai-legion,
docker 29.1.3/compose 2.40): with this exact change the killer image
builds and the #497 crash-injection lane runs to completion on #793's
head — pending swap recovered, post-recovery payment 200
(transcript: /root/src/rel-793/transcript-stock.txt on ai-legion).
* docs(changelog): fragment for the cloud-lab client global-ARG fix
* chore: re-sync the PR head after auto-merge churn (no content change)
---------
Co-authored-by: Amperstrand <amperstrand@users.noreply.github.com>
Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com>
* fix(merchant): receiveTimeout as per-merchant seam — the package var raced leaked PurchaseSession readers (#821) (#837)
* fix(setup): never print generated credentials; add TOLLGATE_PRIVATE_WIFI_PASSWORD (#850)
* fix(setup): never print the generated admin/WiFi credentials; add TOLLGATE_PRIVATE_WIFI_PASSWORD
* docs(changelog): fragment for the no-print credential change
* fix(wireless_gateway_manager): upstream connect refreshes credentials and clears competing STAs (#817) (#848)
* fix(wireless_gateway_manager): upstream connect refreshes credentials and clears competing STAs (#817)
Two defects chained into the desk-run failure:
Reuse: FindOrCreateSTAForSSID returned a disabled STA section for the
SSID without writing the new passphrase — a rotated password was
dropped, the join failed WRONG_KEY, and the flow reported a DHCP wait.
The reuse path now rewrites encryption, key (deleted for open
networks) and network=wwan before returning.
Exclusivity: mt76 exposes ONE STA netdev per radio, so any other
enabled STA section on the target radio keeps the netdev and its
(possibly stale or foreign — the desk run's tollgate_uplink) key is
what wpa_supplicant joins with while the fresh candidate never gets
an interface. SwitchUpstream now disables every enabled STA on the
candidate radio, not just the section GetActiveSTA named; the
auto-switcher in upstream_manager inherits the same guarantee.
Tests drive the real Connector through an in-memory uci fake (the
mock-level tests asserted canned returns and never touched this
logic); all three new tests are planted-drift-verified to fail on the
pre-fix code.
* fix(wireless_gateway_manager): address cross-family review — log the stale-key delete, note the revert side effect
Two non-blocking findings from #848's review: the open-network
transition's key delete now logs failures at debug (the delete is the
safeguard itself; an error was silent), and SwitchUpstream's comment
states that a reverted switch deliberately leaves competing STAs
disabled — the stale key must not regain the netdev — pinned by the
revert test.
* fix(wireless_gateway_manager): map bare 'WPA PSK' scans to psk-mixed
iwinfo reports 'WPA PSK (CCMP)' both for genuine WPA1 networks and for
mixed/WPA2-preferring APs whose beacon leads with the WPA1 IE — measured
on the lab bench: such an AP rejects a psk section's WPA1 auth and only
completes WPA2, so the connect flow wrote a section that could never
handshake and reported a DHCP wait (#817's second root cause, same
operator-visible symptom as the garbage-key defect). psk-mixed negotiates
either way; regression test pins the measured AP shapes.
* test(wireless_gateway_manager): pin the psk-mixed mapping in the legacy DetectEncryption table
The pre-existing table asserted the old WPA1 mapping for 'WPA PSK (TKIP)';
0e3da8f deliberately maps that shape to psk-mixed (iwinfo prints the same
string for genuine WPA1 and for WPA2-preferring APs whose beacon leads
with the WPA1 IE — measured on the bench; psk-mixed negotiates either).
Caught by the full go-battery, which the -run filter had skipped via a
non-matching test name.
---------
Co-authored-by: Amperstrand <dev@amperstrand.no>
* fix(setup): never generate a credential the operator cannot see (M1) (#896)
* fix(setup): never generate a credential the operator cannot see
M1: when root has no password and TOLLGATE_ADMIN_PASSWORD is unset, the
package now generates NOTHING and refuses to serve the admin board, instead
of creating a provisional credential. A generated-but-withheld password is
exactly how routers got locked out (the installer deferred one credential
while this package generated another; the operator held neither).
The admin password must be provided: as TOLLGATE_ADMIN_PASSWORD at install
time, or with `passwd root`, then the gate is re-run. The
generate_admin_password/mark_admin_credential_provisional helpers are
removed; a provisional marker carried over from an older install is still
honoured so an upgraded router keeps telling the operator the credential
was generated.
* changelog: fragment for #884; fix #850 fragment format
The #850 fragment started with a heading instead of a bullet, so
changelog-assemble.py check failed on main. Reformatted it as a bullet.
---------
Co-authored-by: Your Name <you@example.com>
* setup: read the operator credential from a root-only file too (#897)
apk-tools 3 (OpenWrt >= 25.12) does NOT forward the caller's environment to
package scriptlets, so `TOLLGATE_ADMIN_PASSWORD='…' apk add` never reaches
99-tollgate-setup there: the module saw the env empty and refused the admin
board (fail closed, no lockout, but no board). Proven on a GL-MT3000 running
vanilla 25.12.5 / apk-tools 3.0.5.
operator_chosen_admin_password now falls back to a root-only file
(/etc/tollgate/admin-password); the private-WiFi passphrase likewise falls back
to /etc/tollgate/wifi-password. Both are consumed (removed) once applied. The
env still wins when present, so opkg (where the env IS forwarded) is unchanged.
tests/packaging/admin-board-requires-credential_test.sh: +2 cases (staged file
applies when the env is empty; env wins over the file). 80 passed, 0 failed.
Co-authored-by: Your Name <you@example.com>
* setup: never SET a credential — admin or private-WiFi (#884 follow-up) (#902)
The package already stopped GENERATING an admin credential (#884), but it still
SET one from the install environment (TOLLGATE_ADMIN_PASSWORD /
TOLLGATE_PRIVATE_WIFI_PASSWORD) or a staged root-only file
(/etc/tollgate/admin-password, /etc/tollgate/wifi-password). That is the same
hazard one step over: an operator who pasted the run-book's EXAMPLE value
verbatim ended up with root set to a string they never chose, with no link to
it. Remove every credential write.
99-tollgate-setup:
- drop TOLLGATE_ADMIN_PASSWORD / TOLLGATE_PRIVATE_WIFI_PASSWORD,
ADMIN_PASSWORD_FILE / PRIVATE_WIFI_PASSWORD_FILE, operator_chosen_admin_password(),
set_admin_password(), clear_admin_credential_provisional() and the generated
private-WiFi PSK;
- enforce_admin_credential now only ASCERTAINS: set/locked serve, empty/unknown
refuse and drop the board, telling the operator to set one with `passwd root`
(or LuCI) and re-run;
- setup_private_network: preserve an existing passphrase, otherwise leave the
private AP DISABLED (wired br-private is unaffected) until one is set;
- keep reading the legacy admin-credential-provisional marker on upgrade.
CLI:
- `tollgate network private set-password` and the config applier now also set
disabled=0 on the radio they write a passphrase onto, so setting one actually
brings the (disabled-by-default) AP up.
Tests:
- admin-board-requires-credential: rewritten around the new invariant; ADD the
regression that env AND file present leaves root byte-identical, passwd never
invoked, the staged file unconsumed, and the board refused;
- new tests/uci-defaults-private-credential_test.sh: no passphrase => no key,
AP disabled; env/file ignored; existing key preserved+enabled;
- new src/cli/network_setpassword_test.go: set-password and the applier enable.
Docs: README + unreleased changelog fragments updated to match.
Runs green in the module test tiers (only the pre-existing
entry-ui-mapping_test.sh failures remain, identical to main).
Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com>
* chore: VERSION v0.6.0-rc2 (#903)
Stamp the release candidate for the no-generated-credentials work. The feed's
PKG_SOURCE_TAG is read from this file, so the built binary reports v0.6.0-rc2 to
match the package version 0.6.0_rc2.
Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com>
* rebase onto main (post-rc2 + #798 fence + credential series): builds: enforce the two-path doctrine — surface contract test, one-pass matrix split, rails (#713)
Co-authored-by: Amperstrand <dev@amperstrand.no>
* packaging: refuse to install when root has no password (preinst) + VERSION v0.6.0-rc3 (#907)
The :8090 admin board's login is checked against root's /etc/shadow hash, and
rpcd's session.login accepts ANY password while that hash is empty. The package
never sets a credential, so a password-less install would leave an
unauthenticatable board. Rather than install and then withhold the board, refuse
BEFORE unpacking: the preinst exits non-zero on an empty root hash, so nothing is
half-installed and the operator is told to `passwd root` and retry.
- preinst (was a deliberate no-op): dependency-free (awk, no jq — the past
jq-preinst-aborted-upgrades lesson), allows a locked root, bypasses on
IPKG_INSTROOT (offline/image builds) and TOLLGATE_ALLOW_EMPTY_ROOT=1 (CI hatch).
- new tests/packaging/preinst-refuses-empty-root_test.sh (9 checks).
- VERSION -> v0.6.0-rc3.
Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com>
* setup: default to a fixed TollGate credential instead of refusing (#913)
* setup: default to a fixed TollGate credential instead of refusing
The admin board's login is checked against root's /etc/shadow hash, and rpcd's session.login accepts ANY password while it is empty. rc3 refused to install in that state, but a failing pre-install is NON-FATAL under apk-tools 3 (the .apk landed anyway) and requiring `passwd root` before install burdened every operator.
Replace the refusal with a FIXED default: 99-tollgate-setup sets root to TOLLGATE_DEFAULT_CREDENTIAL ("TollGate") when none exists, sets the private (management) WiFi key to the same value, marks the admin credential PROVISIONAL, and serves the board. The admin/config UI forces a change at first login (shipped separately, post-release). No install-time credential is accepted (TOLLGATE_ADMIN_PASSWORD / staged file stay ignored); an operator's own credential is preserved; the write still fails CLOSED if it cannot take.
- preinst refusal -> no-op (the two lanes agree now).
- new tests/packaging/admin-board-defaults-to-tollgate_test.sh (59 checks);
preinst-refuses-empty-root -> preinst-never-refuses; the private-credential
and configui-8090 suites updated to the default contract.
- VERSION -> v0.6.0-rc4.
* setup: mark the defaulted private-WiFi key provisional too
setup_private_network writes /etc/tollgate/private-wifi-credential-provisional when it installs the fixed default passphrase (rather than one the operator chose), so the post-release admin/config UI can force a Wi-Fi passphrase change at first login alongside the admin one. The test asserts it is written only when the default is applied.
---------
Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com>
* docs(packaging): record the decommission plan for the module's own package build (#914)
Freeze the intent, not yet the change: FreedomTechFeed/packages/net/tollgate-wrt/Makefile is the single canonical shipping package; this repo's packaging/Makefile is test/reproducibility only. packaging/README.md lists exactly what the feed consumes (packaging/files/) and the mechanical removal (Package/Build targets, build-ipk/local-build-ipk/build-env helpers, the build-package.yml publish jobs) to execute after the v0.6.0-rc4 release. Draft PR; merged post-release.
Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com>
* test(harness): pin the env seams the setup script now resolves (release-check green) (#915)
Three uci-defaults suites were red at release-check (they are NOT in the per-PR test.yml lane, so #913 merged with them red):
- entry-ui-mapping (9): the suite's sed predated the TOLLGATE_ENTRY_UI_CONFIG env override, so the copied 99 read the HOST's live /etc/tollgate/config.json (the harness rot #899 describes).
- admin-tls-identity (6) + trusted-entry-80 (1): the new private-WiFi provisional marker defaults to an absolute path the suites did not redirect.
Harness-only: pin TOLLGATE_ENTRY_UI_CONFIG + ADMIN/PRIVATE_WIFI_PROVISIONAL_MARKER into each sandbox. NO change to 99-tollgate-setup (the risky half of #899). Sweep: all tests/packaging/* + tests/uci-defaults-* green.
Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com>
* packaging: re-check the private subnet once the uplink is up (#926)
setup_private_network derives network.private (lan/24 ±1) at uci-defaults
time, before a fresh DHCP WAN necessarily has a lease, so an upstream that
lands on the same /24 as the derived private network — the observed
192.168.2.1 against a 192.168.2.0/24 upstream — is missed. The router then
answers for the upstream gateway's own address and DNS breaks on the
deployed router.
Add recheck_private_subnet (no-op unless the private /24 now overlaps a known
upstream) and call it from the postinst AFTER `/etc/init.d/network restart`,
reloading the network only when the private subnet had to move.
Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com>
* fix(tollwallet): bound each mint registration so a slow mint cannot stall boot (#940)
* fix(tollwallet): bound each mint registration so a slow mint cannot stall boot
The wallet already registers only the mints the startup probe found reachable,
but gonuts' AddMint does its own network I/O with NO http.Client timeout, so a
mint that accepts the connection and never answers can stall the whole wallet
construction — the module's own notes measure 113-347 s for 7 mints. That
outlasts the installer's health window and made a healthy install report 'the
merchant is degraded'.
registerMint now gates AddMint behind a bounded GET /v1/keysets with OUR OWN
client (8 s). A mint that does not answer is left UNREGISTERED rather than
blocking boot: ensureMintRegistered retries it on first use and the
reachable-set callbacks admit it when it recovers. Registration stays
sequential, so gonuts is never called concurrently.
Test: TestMintServesKeysets (serves keysets -> accepted; empty -> rejected;
outruns the bound -> rejected). tollwallet + merchant modules green.
* review: reword the bound's guarantee, state the residual, add a defer->recover test
Addresses the independent review on the fork PR:
- The comment no longer claims AddMint 'cannot hang'. The probe and AddMint are
separate exchanges, so the bound makes a hang UNLIKELY rather than impossible;
the real closure (a goroutine-with-timeout around AddMint, or registration off
the boot path) is noted as a follow-up.
- States the residual worst case explicitly: one registerMintKeysetTimeout per
reachable mint, sequentially — 7 mints x 8 s = 56 s at boot — plus the new
first-use latency for a deferred mint (a bounded <=8 s inline probe on the
first payment routed to it; latency, not correctness).
- Adds TestRegisterMintDefersThenRecovers: a mint DOWN at boot is left
unregistered, and ensureMintRegistered (the path Receive takes) registers it
once it recovers — pinning the integration claim through the real gonuts
wallet, not just the probe unit.
---------
Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com>
* docs(operator-guide): fix the entry_ui=luci TLS-port attribution (#939)
The SSL-command scope paragraph paired LuCI's :8443 with entry_ui=luci, inverted: under entry_ui=luci LuCI (uhttpd.main) owns the entry pair TLS port :443 and the board owns :8443. Both ports named in the old sentence belong to the entry_ui=board mapping.
Ground truth: packaging/files/etc/uci-defaults/99-tollgate-setup port functions and docs/access-urls.md's mapping table. Found by the PR #744 review; one-line fix-forward.
* docs: defer loopback rate-limit vs startup-gate follow-up (post-release) (#953)
Record the root cause behind the 2026-10-16 'Setup failed' incident: the
installer's own 3s poll exceeds RateLimitMiddleware's 10 req/min for
127.0.0.1, and the limiter runs before requireStarted, so the probe can get
429 {'error':'rate limit exceeded'} instead of the readable
503 {'code':'starting'} refusal.
The installer side is worked around in rc33. The root-cause fix (exempt
loopback from the limiter) is DEFERRED until after the current release —
no new module RC in this line.
Co-authored-by: c03rad0r <c03rad0r@users.noreply.github.com>
* fix(upstream): select and blacklist upstreams by BSSID, not SSID (#910)
* fix(upstream): select and blacklist upstreams by BSSID, not SSID
One SSID can be served by several BSSIDs across different upstream networks —
some routed to the internet, some captive or isolated. The manager keyed its
blacklist by SSID and never pinned the scanned BSSID on the candidate, so a
router that settled on a BSSID with no internet blacklisted the whole SSID,
including any working sibling, and could never recover. The ICMP-only
connectivity check could not tell "gateway but no internet" from "ok" either.
- Blacklist by BSSID (falling back to the SSID only when it is unknown), skip
candidates by BSSID, and blacklist the exact failed BSSID on an emergency
switch. A sibling BSSID of the same SSID stays eligible.
- Thread the candidate BSSID through SwitchUpstream and pin
wireless.<iface>.bssid, clearing a stale pin when the candidate carries none.
Manual CLI connect pins the strongest scanned BSSID for the SSID.
- Replace the ICMP-only probe with a layered one — default route, DNS
resolution, HTTP 204 captive-portal check — returning a reason
(no-route/no-dns/captive/no-http). Used by the startup check, the main loop
and post-switch verification.
Tests: two-BSSID/one-SSID blacklist isolation, BSSID pin set/clear, and probe
classification. make go-battery green across all 16 modules.
* changelog: fragment for #910 (BSSID-aware upstream selection)
---------
Co-authored-by: c03rad0r <c03rad0r@users.noreply.github.com>
* ci(router-test): TBMG Trust Pipeline + bench lanes (dual twin) (#898)
* ci(router-test): TBMG Trust Pipeline on the self-hosted runner + verdict comment
Router-test is the TBMG Trust Pipeline entry point. Two changes:
* Run the gateway dispatch on the self-hosted runner (role 86-tbmg-runner on
hermes-nvme) over the private fips mesh with its dedicated per-machine key —
no per-repo secrets and no metered LAN traffic.
* Add a report job that posts ONE idempotent verdict comment per PR (marker
<!-- tbmg-trust -->), so every PR shows whether it is ready to merge. Fork
PRs (which must never run on a self-hosted runner) are reported as
"not tested" instead of silently skipped.
The security contract is preserved: PR -> isolated lab; bench only on main or
an explicit dispatch behind bench-hardware.
* docs(changelog): fragment for the TBMG Trust Pipeline (#898)
* ci(router-test): dispatch the bench lanes on the gateway
Add a gated `bench` job (non-PR, bench-hardware) that SSHes to the elected
router-bench-gateway over fips and runs scripts/bench/bench-lanes.sh — the lane
orchestrator (clean flash -> install -> smoke -> radios OFF, JSON summary).
* ci(ngit): mirror the router-test trust pipeline into the ngit twin
Land the trust pipeline in the ngit execution twin too (dual setup): same
plan/router-test/bench jobs, ngit-safe (no GitHub `environment`/`vars`; the
verdict is published by ngit-ci to Nostr, so the report job just summarises).
---------
Co-authored-by: felixfelix-bot <felixfelix-bot@users.noreply.github.com>
* feat(wireless): wire the vendor-IE price producer the scan boundary staged for (#959)
* feat(wireless): wire the vendor-IE price producer the scan boundary staged for
PricePerStep/StepSize were declared across four data models (NetworkInfo,
Gateway, DiscoveryEntry, KnownTollGate) and had no producer: scanner.go
hard-zeroed them with a 'STAGED (this PR)' comment, gated behind the
VendorIEDiscovery flag whose consumer did not exist.
Wire it:
- scanner.go: capture the raw IE blob from the scan's 'IE: <hex>' line and
route network assembly through a new buildNetworkInfo(), which calls the
existing ParseVendorIEsFromScanData() to fill IsTollGate / RawIEs and the
pricing fields. Remove the staged comment.
- vendor_element_manager.go: add pricing TLVs (0x03 price_per_step,
0x04 step_size, both 2-byte big-endian) to the encoder and parser, so the
IE can carry a price hint.
- types.go: TollGateAdvertisement gains PricePerStep/StepSize.
Authority is unchanged and now documented in-code: the IE is an UNSIGNED
hint via OUI identification that selects a candidate for the signed
kind-10021 probe on :2121, which remains the authority for price. A zero
pricing value means 'not advertised here', never 'free'. Pricing TLVs are
dropped (truncated=true) rather than overflowing the 255-byte element or
pushing out the pubkey, because identification outranks the price hint.
Tests (observed RED before the change, GREEN after):
- TestParseIwinfoOutput_PopulatesVendorIEFields
- TestParseIwinfoOutput_NonTollGateIEIgnored
- TestParseIwinfoOutput_TollGateIEWithoutPricingTLVs
- TestParseIwinfoOutput_NoIEMeansNotTollGate
- TestParseIwinfoOutput_MultipleNetworksAndIEs
- TestVendorIE_RoundTripPricing (+4 subtests)
- TestVendorIE_PricingDroppedRatherThanOverflow
Full wireless_gateway_manager suite green, including the five pre-existing
vendor-IE tests (no regressions).
* fix(wireless): parse the vendor element from the format the router actually emits
Hardware verification on tollgate-A04E (2026-10-16) falsified the first
implementation's assumption.
Measured:
- `iwinfo <dev> scan` prints NO information elements at all (grep -c 'IE:'
returned 0), so it can never yield IsTollGate via the vendor element. It
does report hidden APs, as `ESSID: "unknown"` — a literal placeholder.
- `iw dev <dev> scan` is the ONLY element source, and it prints elements
DECODED into named fields (SSID:, RSN:, HT capabilities:, ...), not as a
hex blob. A vendor element appears as `Vendor specific: <colon-hex>`.
- iw strips the element ID (0xDD) and the length byte from that body. Feeding
the body straight to ParseVendorIEsFromScanData returns 0 elements: measured
as 36 good bytes in, 0 advertisements out. The body must be re-framed with
its 0xDD + length prefix.
Change:
- ParseIwinfoOutput now accepts BOTH real formats: iwinfo `Cell NN -
Address:` records and iw `BSS <mac>(on ...)` records, per-record, so a
stream carrying either parses.
- SSID parsing handles both keys; RSN supplies encryption for iw output
(it has no `Encryption:` line); `freq:` supplies the channel.
- The vendor element is re-framed before parsing.
- Placeholder SSIDs ("", "(hidden)", "unknown") are refused admission, so a
literal cannot become a selectable upstream.
Tests rewritten against the measured format:
TestParseIWScanOutput_{PopulatesVendorIEFields,NonTollGateVendorIgnored,
TollGateWithoutPricingTLVs,NoVendorElementMeansNotTollGate,
RealHardwareHiddenSSID,MultipleNetworksAndVendorElements}.
Three failed against the raw-body bug and pass with the re-frame; the
full wireless_gateway_manager suite is green with no regressions.
* fix(wireless): read VendorIEDiscovery from UpstreamWifi; scan via iw when enabled
Build fix: the flag lives on the UpstreamWifi sub-struct, not on Config.
With that wired, the scan path can finally populate the fields the vendor
element carries. Two facts measured on tollgate-A04E (2026-10-16) drive the
design:
1. `iwinfo <dev> scan` prints NO information elements, so it can never
identify a TollGate by vendor element. It is kept because it reports
hidden APs (as ESSID "unknown") that iw can omit.
2. `iw dev <dev> scan` is the only element source. When vendor-IE discovery
is enabled, scanRadio runs it as well and mergeScanResults folds the two
views by BSSID, letting the element-derived fields (IsTollGate, RawIEs,
price, step) come from iw while iwinfo still contributes entries iw lacks.
iw is best-effort: a failure or empty result keeps the iwinfo view rather
than failing the scan.
* test(wireless): cover mergeScanResults, scanRadioWithIW and the flag wiring
Coverage on the newly added code was incomplete: mergeScanResults and
scanRadioWithIW were both at 0%, and parseColonHex at 75%. The merge is the
join that lets element-derived fields come from iw while iwinfo-only entries
(notably hidden APs) survive, so an untested merge was the largest risk in
this change.
Added:
- mergeScanResults: enriches an existing BSSID from the second view; keeps
entries only one view saw; fills gaps (signal/encryption); is the identity
when the second view is empty.
- scanRadioWithIW: a failed iw invocation returns nil rather than propagating
an error, so the caller keeps the iwinfo view.
- NewScanner/SetVendorIEDiscovery wiring.
Also moves the on-router demo to src/cmd/tg-vendor-ie-demo, so it compiles
against the repo's own module graph (the nested scanner module is reached via
the existing replace directives) instead of a hand-rolled go.mod.
Coverage on the new surface is now: mergeScanResults 100%, appendScanEntry
100%, NewScanner 100%, SetVendorIEDiscovery 100%, ParseIwinfoOutput 98.6%,
buildNetworkInfo 92.9%, scanRadioWithIW 70%, parseColonHex 75%.
* docs(wireless): record the cross-build target defect found on hardware
The demo binary was built with GOOS=linux GOARCH=arm64 but the router rejected
it with 'syntax error: unexpected end of file'. Its ELF header read
02 00 3e 00 (e_machine 0x3e = x86-64) instead of 02 00 b7 00 (0xb7 = aarch64).
The sha256 matched the local file exactly, so the transfer was correct — the
build target was wrong. Adding CGO_ENABLED=0 produced a proper statically
linked aarch64 binary.
The check that would have caught it in one line, now recorded for reuse:
head -c 20 <binary> | od -An -tx1 | tail -1 # want '02 00 b7 00' for aarch64
Size and sha256 are NOT evidence of the right architecture; both matched while
the binary was unrunnable on the target.
* feat(demo): add --file mode and record the on-router demo evidence
The demo could only run 'iw dev <dev> scan' live. Where no neighbour running
this build exists (as on tollgate-A04E), that can only ever show the ABSENCE of
a vendor element, never the positive identification path. Adding '--file <path>'
lets the parser be run against a saved scan, so a synthetic element can be
injected into REAL captured router output.
Evidence from the live MT6000 (GL.iNet GL-MT6000, OpenWrt 24.10.2,
aarch64_cortex-a53), 2026-10-16:
A. live scan:
iw dev phy0-ap0 scan: 28736 bytes, err=<nil>
Vendor specific: lines present = 0
parsed networks = 12
identified TollGates = 0 (correct: no neighbour runs this build)
B. real scan + one injected TollGate element:
read /tmp/injected-scan.txt: 35168 bytes
Vendor specific: lines present = 1
parsed networks = 16
TOLLGATE bssid=b2:36:4a:2a:03:32 ssid="StarGate" signal=0
price_per_step=7 step_size=1000 rawIE_bytes=38
identified TollGates = 1
-> the parser located the element inside genuine scan data and recovered
the encoded price/step exactly.
The demo binary was cross-built with CGO_ENABLED=0 and its ELF machine field
verified as 0xb7 (aarch64) BEFORE transfer — the check whose absence produced
the unrunnable x86-64 binary recorded in the previous commit.
* fix(wireless): emit pricing before the pubkey so an oversized pubkey fails loudly
Defect found by a cold cross-family review (deepseek-flash, PR #959, 2026-10-16).
With the original ordering a pubkey could pass the fit check and land the body
at 253-255 bytes; the pricing TLVs were then appended AFTER it, the 4-byte guard
fired, and the pricing was silently dropped while the element still reported
success. A router whose pubkey drifted large would appear to advertise no
price — indistinguishable from a router that advertises none.
Fix: emit pricing BEFORE the pubkey. An oversized pubkey now fails the existing
loud error path ('vendor IE body too long') instead of silently losing the
price. The length octet is also clamped explicitly with truncated=true, so a
wrap can never produce a malformed element silently.
Tests (observed RED against the original ordering, GREEN after):
- TestEncode_PricingNotDroppedByPubkey (fails: 'pubkey 239 bytes cannot fit')
- TestEncode_PricingSurvivesWithMaxFitPubkey (loud error for oversized pubkey)
- TestEncode_LengthOctetNeverWraps (octet == body length)
Encoder coverage 87.2%. Full wireless_gateway_manager suite green; go vet clean.
MOUNTED BINARY VERIFIED: demo rebuilt for the router and its ELF machine field
checked as 02 00 b7 00 (aarch64) before any transfer.
Credit: this defect was surfaced by the cross-family lane, not by me.
* refactor(wireless): rename the scan-side price hint so it cannot be confused with the authoritative one
Follow-up from a cold cross-family review of PR #959 (deepseek lane, n…
…rtefact) (#1088) * feat(packaging): let the operator choose the admin password at install time (#827) A router whose root password was unset received a GENERATED credential, printed exactly once. Its only other copy went to /tmp/tollgate-setup.log -- tmpfs, and truncated at the start of every full-setup run -- so an install or upgrade erased the record. "Shown once" is only safe if there is a way not to need it. Two changes: - The install honours TOLLGATE_ADMIN_PASSWORD. uci-defaults run from the package manager with no controlling terminal, so the environment is where a choice can arrive; this is also the mechanism a fleet or CI provisioning script uses. The value is applied to root, never echoed to the output or the setup log, and replaces an existing credential when supplied. If it does not take, the gate fails closed and drops the admin listeners rather than leaving the board on the old credential. - A credential this package GENERATES is now marked provisional by /etc/tollgate/admin-credential-provisional (no secret in it -- the file is world-readable). The marker is (a) the signal the config UI reads to force a password change at first login and (b) the reason a later setup run re-states the recovery path instead of implying the operator chose it. A locked root account and an unreadable shadow stay exempt: neither is a state this package should decide to unlock. Tests extend tests/packaging/admin-board-requires-credential_test.sh (its own harness, per repo doctrine). RED observed first: 5 failures against the pre-change script with the 77 existing assertions passing. Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * fix(packaging): declare the CA bundle and state the manual-install trust chain (#828) * fix(packaging): declare the CA bundle and state the manual-install trust chain Installing the shipped package by hand on a GL-MT3000 (OpenWrt 25.12.5) failed at the first HTTPS fetch: wget AND apk update both died with "SSL verify error: unknown error". The image had no CA store, so no TLS connection could be verified. This module's job is money. It talks to Cashu mints and Lightning over TLS, the Go binary uses the system root pool (no InsecureSkipVerify, no bundled pool), so without a CA bundle every mint and Lightning call fails verification -- a gateway that cannot verify TLS cannot take a payment. The installer used to paper over this by fixing the box first; installing the package alone has to work, so ca-bundle is now a declared dependency rather than an assumption. The docs also gain the chain that makes an unsigned artifact safe to install by hand, because "install it yourself" is only honest if the verification is stated: the release's SHA256SUMS is signed (SSHSIG/ed25519, public half at .github/release-keys/release-signing.pub), so verify the signature FIRST, then check the artifact hash, then install. The artifact carries no apk-level signature, which is exactly why the manifest must be verified before the bytes are trusted. On a stock router ssh-keygen is absent (dropbear ships, not OpenSSH), so the manifest is verified on a trusted machine and the hash checked on the router. tests/packaging/apk-install-resolution_test.sh gains source-level assertions for both declarations, run BEFORE its docker gate so a machine without docker still checks them, and its header's honesty list now says what it does and does not prove about trust. RED observed first (both source files reverted, test kept): the CA assertion and two of the three documented-chain assertions fail. GREEN after: all four pass and the docker closure lane still resolves (12.5 MiB, 156 packages). * docs(packaging): point manifest verification at allowed_signers, not the bare key The README named .github/release-keys/release-signing.pub as the key to use, while the command it printed passed -f allowed_signers. Those are different files in different formats: release-signing.pub is a bare ed25519 public key, allowed_signers is the same key prefixed with the principal the -I identity is matched against. Following the prose sent the reader to a file the verifier rejects. Name the file the verifier consumes and fetch it explicitly. The lane-pinning needle added here is a PRESENCE pin only: allowed_signers already appeared in the printed command, so it does not go RED against the pre-fix README. This fixes documentation accuracy, not behaviour. * fix(packaging): declare the CA bundle on the .ipk lanes too The CA-bundle dependency was declared only in the SDK recipe (packaging/Makefile). The two .ipk build lanes -- packaging/local-build-ipk.sh and .github/workflows/build-package.yml -- still stamped `Depends: libc, nodogsplash, jq`, and scripts/ngit-gen-shards.py propagated the same line into every generated .ngit shard, so the OpenWrt <=24.10 opkg lane installed a module with no CA store and the same class of TLS failure the apk lane hit was still reachable there. Declare ca-bundle in the recipe, the GitHub lane and the shard generator, and regenerate the shards (scripts/ngit-gen-shards.py --check is clean). The sibling contract test now expects the exact new Depends value rather than a prefix of it, so a future drop of the bundle is a failure and not a still-matching prefix. This is the dependency half of the same defect class: it fixes an install on a box that can still reach the feed. It does not, and cannot, bootstrap a box with no trust anchors -- that path is stated in the README. Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * docs(packaging): state the honest offline bootstrap and pin the key file The README claimed the +ca-bundle dependency makes a hand install work. It does not close the failure the change reports: on the box the defect was found on, apk update itself died with an SSL verify error, and apk must fetch ca-bundle over the same TLS it cannot verify. The dependency is correct for a box that CAN reach the feed; it cannot repair one with no trust anchors. Say so. The dependency fixes a feed-reachable box (the closure is resolved and ca-bundle comes with it). For a box with no trust anchors the README now gives the offline path instead: bring the package files over by other means (scp) and install them locally in one apk call under a single explicit trust override, with the opkg equivalent for the <=24.10 lane and the clock caveat. Verified in a container (openwrt/rootfs 25.12, CA store removed): a local `apk add --allow-untrusted ./pkg.apk` completes even while every HTTPS fetch fails with an SSL verify error, because the local file install does not need the fetch to succeed. Two accuracy fixes in the same section: pin the fetched key file to a commit SHA instead of the moving master branch, and name the real fetchers on a stock router (wget or uclient-fetch, not curl) -- curl is as absent as ssh-keygen, so the previous note listed only half of what is missing. Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * docs(changelog): record installer-free-trust as added, without the overclaim The fragment narrated added behaviour and docs, so .changed was the wrong suffix; rename it to .added (the repo convention, see vm-campaign.added.md and 679-changelog-fragments.added.md) and reword it to match what the change now does: ca-bundle is a declared dependency on every build lane, which fixes an install on a box that can reach the feed, and the README states the offline path for a box that cannot -- rather than implying the dependency alone bootstraps an anchor-less box. Also drop the release-signing.pub reference: the verifier consumes the allowed-signers file, not the bare key beside it. That mismatch was fixed once already in 084b1179 and the fragment reintroduced it. Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * test(packaging): assert the CA dependency from build metadata, not grepped text apk-install-resolution_test.sh "proved" the CA-bundle declaration and the documented trust chain by grepping README.md and packaging/Makefile for literal strings. That was tautological, and the three failure modes are reproducible against the old test: - a README that tells the reader to never use the override still passes (the literal string is still present); - a dependency living only inside a `# ... +ca-bundle` comment still passes; - a formatting-only Makefile edit (extra spaces) FAILS, because the regex pins the source text rather than the declaration. Replace both greps with assertions over build metadata: (1) apk/SDK lane: expand packaging/Makefile with `make -pn` and require the bundle in the DEPENDS of the package definition make reads. Comment lines are dropped before matching, so a dependency that exists only in a comment cannot satisfy it, and whitespace no longer matters; (2) opkg/.ipk lane: build a real .ipk from the recipe's DEPENDS with packaging/build-ipk.sh and assert the `Depends:` field of the control file opkg consumes -- the byte-level artefact, which raw text cannot fake. Both run before the docker gate and both fail closed. The header now states exactly what each check proves and what it does not (the SDK expansion proves the recipe declares it, not that a build emitted it; the artifact check is the load-bearing one). RED observed first, then GREEN: - dependency moved into a comment in packaging/Makefile -> check (1) FAIL, exit 1 (the old test passed this case); - ca-bundle removed from packaging/local-build-ipk.sh -> check (2) FAIL, exit 1; - extra whitespace only in packaging/Makefile -> both checks PASS (the old test failed this case); - old test vs a README that says "do NOT ever use this override" -> old test PASS, exit 0 (the tautology, reproduced); - all 12 tests/packaging suites pass with the new test in place. Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * docs(packaging): verify the trust anchor before installing it locally * docs(packaging): qualify the feed DEPENDS citation (ca-bundle lands with packages#57) * docs(changelog): lead with the declared dependency, keep the GL-MT3000 as evidence * docs(readme): fail-closed on every staged package file, not just the first --------- Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> Co-authored-by: Felix <felix@opentollgate.org> Co-authored-by: Felix <felix@freedomtechfeed.org> * fix(packaging): run the fail-closed admin gate LAST so the credential exists first (#838) * fix(packaging): run the fail-closed admin gate LAST so the credential exists first The admin board's setup script (staged from the pinned portal tree) is fail closed: while root's /etc/shadow hash is empty it DELETES uhttpd.admin's listeners, on the principle that the board must be unreachable rather than merely unhelpful. 99-tollgate-setup is what CREATES that credential. The recipes ran the uci-defaults in numeric order (90, 92, 99), so on a fresh hand-install the gate ran BEFORE the credential existed and killed the admin board with nothing to restore it. Stage the portal's 92-tollgate-admin-setup as 999-tollgate-admin-setup instead, so it sorts after 99-tollgate-setup in the boot glob, and run 90, 99, 999 on both install recipes (the packaging/Makefile postinst loop and packaging/postinst). The numeric prefix IS the boot order, because /etc/init.d/boot applies /etc/uci-defaults/* in one collated glob, so an install and the next boot now agree on the last writer. Also resolve the gate's operator-facing messages from the same entry_ui mapping the listener drop uses (:8080/:443 in board mode, :8090/:8443 in luci mode) instead of a hardcoded :8090, so the refusal and the board's own script name the same URL for one board. tests/packaging/uci-defaults-run-order_test.sh pins the gate-last contract - the credential runs before the gate, the gate runs last, and the gate's installed filename sorts after the credential script's - judged by one predicate, with the pre-change numeric order and a recipe-only reorder as negative controls and the gate-last order as the positive control. * test(packaging): keep the admin-board suite off the build host The suite exercised the shipped driver in a sandbox but left three ABSOLUTE paths pointed at the real filesystem: * /etc/tollgate/entry-ui-mapping -- READ. A marker left on the build host leaks in and flips entry_ui, and with it the board's pair. Measured 2026-10-09: a stray marker containing `board` turned this suite's expected ':8090/:8443' refusal into ':8080/:443' and failed it on an otherwise green tree (the only net-new failure in this branch). * /etc/tollgate/config.json -- READ, and not overridable at all. * /etc/tollgate/admin-credential-provisional -- WRITTEN. An un-sandboxed run deposits a generated credential in the build host's /etc/tollgate. Pin all three inside $TMP, and make ENTRY_UI_CONFIG overridable via TOLLGATE_ENTRY_UI_CONFIG -- the same `${VAR:-default}` seam the adjacent ENTRY_UI_MARKER and ENTRY_UI_JQ_BIN already use, so the harness needs no new mechanism. Verified, packaging tier, same loop on both trees: origin/main (22f111ab): 267 passed / 33 failed this branch : 277 passed / 9 failed The 9 remaining failures are in configui-8090-single-owner_test.sh and are BYTE-IDENTICAL to main's (diffed the failing assertion text) -- pre-existing double-ownership of :8090, untouched here. --------- Co-authored-by: Felix <felix@opentollgate.org> Co-authored-by: Felix <felix@freedomtechfeed.org> * docs(agents): firewall/naming rules of engagement, verification-venue map, changelog fragment convention (#800) * docs(agents): firewall/naming rules of engagement, verification-venue map, changelog fragment convention Three additions distilled from the 2026-10-09 stop-ship reviews and bench sessions (#754/#755/#756/#757, PR #782's first design): - Firewall, nftables and topology-naming rules of engagement: accept is not final across base chains (a green render-level contract suite hid a dead design in #782), no runtime nft inserts, defines-not-literals including generated fragments (34- generator is the open instance; #601's planned 33- is pre-committed), same-priority cross-family ordering fragility, fw4-reload NDS-state-wipe disclosure duty, the three-namespaces 'lan' hazard + netifd wireless regen, and the Go-side interface-literal grep list. - Where each class of verification lives: contract suites (mutation-check duty; a cited gate must test the cited thing), per-era bench lanes with an image-doctor preflight, PRTA as the home for topology/journey scenarios, cloud-lab crash lanes, the physical tier. - Changelog requirements rewritten to the changelog.d fragment convention, fixing the AGENTS.md / changelog.d-README split-brain that mis-led three PRs in one week. * docs(changelog): fragment for the AGENTS.md knowledge rules (#800) --------- Co-authored-by: Amperstrand <dev@amperstrand.no> Co-authored-by: Amperstrand <amperstrand@users.noreply.github.com> * test(merchant): late-receive outcome selection raced the owed-grant ERROR line — pick the COMPLETED record by name (#681 red herring) (#718) * test(merchant): duplicate-guard harness needs an ndsctl on PATH — install the renewal fake (#822) (#835) * test(merchant): FiredOnce pinned the opposite of the wired contract — assert fire-exactly-once deterministically (#732) (#836) * test(contract): SSID matcher fence — pinned acceptance table for hasTollGateSSID (#618/#706 classes) (#839) The reader twin of check-ssid-format.sh: what the WRITER may emit is pinned there, what the MATCHER must accept is pinned here. One fixture table (tests/contract/ssid-naming-fixtures.txt) drives both the Go unit test and tests/contract/check-ssid-naming.sh (structural: table covers every required class and the Go pin is wired; behavioural: the real go test runs over the table). Classes: both brands bare (#618 — the matcher was brand-blind and a whitelabel upstream was invisible), case- insensitive (lowercase installer-era SSIDs still in the fleet), single-'!'-decorated with double-'!' rejected (#706). Lands #706's reader-side tolerance (trim at most one '!') so the table is green on merge; #706 then only needs its writer/installer half. The whitelabel fixtures carry the brand literal in the table's file — the SSID contract's allowlisted home in the rebrand gutter (same exemption as check-ssid-format.sh); Go source keeps assembling it from non-contiguous bytes. Negative control: a planted contradictory fixture line turns the fence red (behavioural layer), removed again. Refs #767 Co-authored-by: Amperstrand <dev@amperstrand.no> Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * test(merchant): closeGateCleanup clears an abandoned close streak — the streak outlived the test that spent it (#841) (#842) * fix(wallet,merchant): correlate NUT-07 answers by value; fsync journal directories after rename (#834) (#876) * fix(wallet,merchant): correlate NUT-07 answers by value; fsync journal directories after rename (#834) Two audit-hardening halves of #834: - CheckTokenSpent counted answered states but never correlated each response Y back to a requested one. It now correlates by value: an answer about a Y we did not ask about, or a duplicate answer padding a short reply, is an error instead of a silent verdict — the #502 pending-intent reconciliation (abandoned vs owed) decides on this evidence, and NUT-07 requires states to match the request. - the three money journals (receive intents, owed grants, quote store) fsynced the temp file but not the directory after rename; on ext4-class filesystems the rename itself is only durable after the directory entry is synced, so a power cut could drop the newest journal write whose contents were already fsynced. syncParentDir closes the gap after every rename, surfacing a failed dir fsync as a save error (the record must be treated as not-yet-durable, exactly like a failed temp Sync). * changelog: fragment for #840 (NUT-07 value correlation + journal dir fsyncs) * test(tollwallet): pin the NUT-07 shortfall refusal — direct coverage for the answered-N-of-M branch --------- Co-authored-by: Amperstrand <dev@amperstrand.no> Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * fix(deps): the gonuts pin is one version, everywhere — manifest truth, glob fence, mechanical bump (#791) (#798) The wallet fork's pin drifted three ways in one release week: a hand bump moved four carriers and left a fifth (scripts/token-recovery) three minors behind; two open PRs pinned the same lines to a tag and a pseudo-version; a newer fork tag carrying an open-P2 fix went unnoticed. - packaging/build-inputs.json names the pin (.gonuts.version) as the single source of truth, beside the go and portal pins. - tests/contract/check-gonuts-pin.py discovers carriers by glob (both go.mod require shapes — the single-line form hid the token-recovery stale pin from the checker's first draft) and refuses any disagreement, offline, in pre-commit and both CI twins; --latest (release-check only) refuses a release while the fork has a newer stable tag. - tests/contract/gonuts-pin_test.sh: 7 planted-drift verdicts. - scripts/bump-gonuts.sh rewrites manifest and every carrier together. - scripts/token-recovery raised v0.10.0 -> v0.13.0 (the fence's first catch; builds and vets clean against the new pin). - AGENTS.md states the rule and the check, never a carrier count. Co-authored-by: pr-train-lane <lane@opencode> * fix(packaging): allow pre-auth DNS on UDP, not only TCP — users_to_router udp/53 (#749) (#769) The nodogsplash pre-auth allow list carried tcp/53 (the feed default) but not udp/53, and stub resolvers query UDP first: the ndsRTR chain dropped the query, the client burned a ~5s timeout per attempt before any TCP fallback, and hostname resolution from a captive client was dead in practice — while the portal page itself is usually reached by name (local-lab campaign behind PRTA PR #101). assert_nodogsplash_allow_entries now asserts BOTH protocols, with a new protocol-aware guard (uci_list_has_entry): uci_list_has_port matches a port on either protocol, so guarding udp/53 with it would see the feed's tcp/53 entry and skip the add. tcp/53 is asserted alongside (not left to the feed default) because the writer's contract is that a stale or absent list is repaired on every setup path. tests/uci-defaults-dns-preauth_test.sh pins the matrix against the shipped writer via the fake-uci harness: feed-default list gains udp/53 without duplicating tcp/53, a stale list is repaired on both protocols, the guard is proven protocol-aware (udp present + tcp missing still adds tcp), reruns add nothing, and uci's quoted single-line rendering is handled. The same-version allowlist and convergence suites carry the DNS entries through their full-script runs; CI runs the new suite on every push. Fixes #749 * chore(packaging): pin the portal at the merged network-settings revision (#766) The portal pin moves to 6c25834c (OpenTollGate/tollgate-captive-portal-site#66, merged) — the board half of the operator settings whose module half landed in #604: the Settings cards for the private network's credentials and the administration scope, and the WiFi page's private-radio edit routed through `tollgate config_set` with the section-name contract pinned (both `wireless.private_radio0/1` names and the structural AP-on-`private` signal, read in the netifd `config`-nested shape and the flat fallback) and the half-applied save named rather than swallowed. The gate trail is on the PR: rounds 1-3 CHANGES-REQUESTED, round 4 flagged the structural net reading the wrong shape, round 5 delta verification APPROVED. Evidence: 23/23 Playwright specs across the three admin suites, 4/4 unit tests, tsc clean, packaging guards and the anti-leak gate green. The .secrets.baseline entry for the pin line moves with it, per the baseline's documented purpose of recording reviewed pins: a pin whose hash is not recorded is (correctly) a new alert. As with #714, the pin is the atomicity boundary: the module's rc1 changelog entry already says these settings are settable "on the board" — only a release built after this commit actually carries the board half. Co-authored-by: Amperstrand <amperstrand@localhost> * fix(cloud-lab): Dockerfile.client's GO_VERSION must be a global ARG — the client image cannot build from scratch (#844) * fix(cloud-lab): Dockerfile.client's GO_VERSION must be a GLOBAL arg — the client image cannot build from scratch Dockerfile spec: only args declared before the FIRST FROM are visible to FROM lines. Dockerfile.client declared ARG GO_VERSION=1.26.8 inside the rust-builder stage (after FROM rust:1.88-bookworm) and then used it in the go-builder stage's FROM — where it resolves empty, so every from-scratch build of the client/killer image fails: failed to solve: failed to parse stage name "golang:-bookworm": invalid reference format Introduced by #687's toolchain-parity sweep, which added the ARG to both cloud-lab Dockerfiles — correctly placed in Dockerfile.tollgate (before its first FROM), mid-file in Dockerfile.client. Nothing caught it because the org CI that builds these images has been dark and local lanes ran on cached images. Move the ARG above the first FROM. Verified on the rig (ai-legion, docker 29.1.3/compose 2.40): with this exact change the killer image builds and the #497 crash-injection lane runs to completion on #793's head — pending swap recovered, post-recovery payment 200 (transcript: /root/src/rel-793/transcript-stock.txt on ai-legion). * docs(changelog): fragment for the cloud-lab client global-ARG fix * chore: re-sync the PR head after auto-merge churn (no content change) --------- Co-authored-by: Amperstrand <amperstrand@users.noreply.github.com> Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * fix(merchant): receiveTimeout as per-merchant seam — the package var raced leaked PurchaseSession readers (#821) (#837) * fix(setup): never print generated credentials; add TOLLGATE_PRIVATE_WIFI_PASSWORD (#850) * fix(setup): never print the generated admin/WiFi credentials; add TOLLGATE_PRIVATE_WIFI_PASSWORD * docs(changelog): fragment for the no-print credential change * fix(wireless_gateway_manager): upstream connect refreshes credentials and clears competing STAs (#817) (#848) * fix(wireless_gateway_manager): upstream connect refreshes credentials and clears competing STAs (#817) Two defects chained into the desk-run failure: Reuse: FindOrCreateSTAForSSID returned a disabled STA section for the SSID without writing the new passphrase — a rotated password was dropped, the join failed WRONG_KEY, and the flow reported a DHCP wait. The reuse path now rewrites encryption, key (deleted for open networks) and network=wwan before returning. Exclusivity: mt76 exposes ONE STA netdev per radio, so any other enabled STA section on the target radio keeps the netdev and its (possibly stale or foreign — the desk run's tollgate_uplink) key is what wpa_supplicant joins with while the fresh candidate never gets an interface. SwitchUpstream now disables every enabled STA on the candidate radio, not just the section GetActiveSTA named; the auto-switcher in upstream_manager inherits the same guarantee. Tests drive the real Connector through an in-memory uci fake (the mock-level tests asserted canned returns and never touched this logic); all three new tests are planted-drift-verified to fail on the pre-fix code. * fix(wireless_gateway_manager): address cross-family review — log the stale-key delete, note the revert side effect Two non-blocking findings from #848's review: the open-network transition's key delete now logs failures at debug (the delete is the safeguard itself; an error was silent), and SwitchUpstream's comment states that a reverted switch deliberately leaves competing STAs disabled — the stale key must not regain the netdev — pinned by the revert test. * fix(wireless_gateway_manager): map bare 'WPA PSK' scans to psk-mixed iwinfo reports 'WPA PSK (CCMP)' both for genuine WPA1 networks and for mixed/WPA2-preferring APs whose beacon leads with the WPA1 IE — measured on the lab bench: such an AP rejects a psk section's WPA1 auth and only completes WPA2, so the connect flow wrote a section that could never handshake and reported a DHCP wait (#817's second root cause, same operator-visible symptom as the garbage-key defect). psk-mixed negotiates either way; regression test pins the measured AP shapes. * test(wireless_gateway_manager): pin the psk-mixed mapping in the legacy DetectEncryption table The pre-existing table asserted the old WPA1 mapping for 'WPA PSK (TKIP)'; 0e3da8f deliberately maps that shape to psk-mixed (iwinfo prints the same string for genuine WPA1 and for WPA2-preferring APs whose beacon leads with the WPA1 IE — measured on the bench; psk-mixed negotiates either). Caught by the full go-battery, which the -run filter had skipped via a non-matching test name. --------- Co-authored-by: Amperstrand <dev@amperstrand.no> * fix(setup): never generate a credential the operator cannot see (M1) (#896) * fix(setup): never generate a credential the operator cannot see M1: when root has no password and TOLLGATE_ADMIN_PASSWORD is unset, the package now generates NOTHING and refuses to serve the admin board, instead of creating a provisional credential. A generated-but-withheld password is exactly how routers got locked out (the installer deferred one credential while this package generated another; the operator held neither). The admin password must be provided: as TOLLGATE_ADMIN_PASSWORD at install time, or with `passwd root`, then the gate is re-run. The generate_admin_password/mark_admin_credential_provisional helpers are removed; a provisional marker carried over from an older install is still honoured so an upgraded router keeps telling the operator the credential was generated. * changelog: fragment for #884; fix #850 fragment format The #850 fragment started with a heading instead of a bullet, so changelog-assemble.py check failed on main. Reformatted it as a bullet. --------- Co-authored-by: Your Name <you@example.com> * setup: read the operator credential from a root-only file too (#897) apk-tools 3 (OpenWrt >= 25.12) does NOT forward the caller's environment to package scriptlets, so `TOLLGATE_ADMIN_PASSWORD='…' apk add` never reaches 99-tollgate-setup there: the module saw the env empty and refused the admin board (fail closed, no lockout, but no board). Proven on a GL-MT3000 running vanilla 25.12.5 / apk-tools 3.0.5. operator_chosen_admin_password now falls back to a root-only file (/etc/tollgate/admin-password); the private-WiFi passphrase likewise falls back to /etc/tollgate/wifi-password. Both are consumed (removed) once applied. The env still wins when present, so opkg (where the env IS forwarded) is unchanged. tests/packaging/admin-board-requires-credential_test.sh: +2 cases (staged file applies when the env is empty; env wins over the file). 80 passed, 0 failed. Co-authored-by: Your Name <you@example.com> * setup: never SET a credential — admin or private-WiFi (#884 follow-up) (#902) The package already stopped GENERATING an admin credential (#884), but it still SET one from the install environment (TOLLGATE_ADMIN_PASSWORD / TOLLGATE_PRIVATE_WIFI_PASSWORD) or a staged root-only file (/etc/tollgate/admin-password, /etc/tollgate/wifi-password). That is the same hazard one step over: an operator who pasted the run-book's EXAMPLE value verbatim ended up with root set to a string they never chose, with no link to it. Remove every credential write. 99-tollgate-setup: - drop TOLLGATE_ADMIN_PASSWORD / TOLLGATE_PRIVATE_WIFI_PASSWORD, ADMIN_PASSWORD_FILE / PRIVATE_WIFI_PASSWORD_FILE, operator_chosen_admin_password(), set_admin_password(), clear_admin_credential_provisional() and the generated private-WiFi PSK; - enforce_admin_credential now only ASCERTAINS: set/locked serve, empty/unknown refuse and drop the board, telling the operator to set one with `passwd root` (or LuCI) and re-run; - setup_private_network: preserve an existing passphrase, otherwise leave the private AP DISABLED (wired br-private is unaffected) until one is set; - keep reading the legacy admin-credential-provisional marker on upgrade. CLI: - `tollgate network private set-password` and the config applier now also set disabled=0 on the radio they write a passphrase onto, so setting one actually brings the (disabled-by-default) AP up. Tests: - admin-board-requires-credential: rewritten around the new invariant; ADD the regression that env AND file present leaves root byte-identical, passwd never invoked, the staged file unconsumed, and the board refused; - new tests/uci-defaults-private-credential_test.sh: no passphrase => no key, AP disabled; env/file ignored; existing key preserved+enabled; - new src/cli/network_setpassword_test.go: set-password and the applier enable. Docs: README + unreleased changelog fragments updated to match. Runs green in the module test tiers (only the pre-existing entry-ui-mapping_test.sh failures remain, identical to main). Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * chore: VERSION v0.6.0-rc2 (#903) Stamp the release candidate for the no-generated-credentials work. The feed's PKG_SOURCE_TAG is read from this file, so the built binary reports v0.6.0-rc2 to match the package version 0.6.0_rc2. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * rebase onto main (post-rc2 + #798 fence + credential series): builds: enforce the two-path doctrine — surface contract test, one-pass matrix split, rails (#713) Co-authored-by: Amperstrand <dev@amperstrand.no> * packaging: refuse to install when root has no password (preinst) + VERSION v0.6.0-rc3 (#907) The :8090 admin board's login is checked against root's /etc/shadow hash, and rpcd's session.login accepts ANY password while that hash is empty. The package never sets a credential, so a password-less install would leave an unauthenticatable board. Rather than install and then withhold the board, refuse BEFORE unpacking: the preinst exits non-zero on an empty root hash, so nothing is half-installed and the operator is told to `passwd root` and retry. - preinst (was a deliberate no-op): dependency-free (awk, no jq — the past jq-preinst-aborted-upgrades lesson), allows a locked root, bypasses on IPKG_INSTROOT (offline/image builds) and TOLLGATE_ALLOW_EMPTY_ROOT=1 (CI hatch). - new tests/packaging/preinst-refuses-empty-root_test.sh (9 checks). - VERSION -> v0.6.0-rc3. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * setup: default to a fixed TollGate credential instead of refusing (#913) * setup: default to a fixed TollGate credential instead of refusing The admin board's login is checked against root's /etc/shadow hash, and rpcd's session.login accepts ANY password while it is empty. rc3 refused to install in that state, but a failing pre-install is NON-FATAL under apk-tools 3 (the .apk landed anyway) and requiring `passwd root` before install burdened every operator. Replace the refusal with a FIXED default: 99-tollgate-setup sets root to TOLLGATE_DEFAULT_CREDENTIAL ("TollGate") when none exists, sets the private (management) WiFi key to the same value, marks the admin credential PROVISIONAL, and serves the board. The admin/config UI forces a change at first login (shipped separately, post-release). No install-time credential is accepted (TOLLGATE_ADMIN_PASSWORD / staged file stay ignored); an operator's own credential is preserved; the write still fails CLOSED if it cannot take. - preinst refusal -> no-op (the two lanes agree now). - new tests/packaging/admin-board-defaults-to-tollgate_test.sh (59 checks); preinst-refuses-empty-root -> preinst-never-refuses; the private-credential and configui-8090 suites updated to the default contract. - VERSION -> v0.6.0-rc4. * setup: mark the defaulted private-WiFi key provisional too setup_private_network writes /etc/tollgate/private-wifi-credential-provisional when it installs the fixed default passphrase (rather than one the operator chose), so the post-release admin/config UI can force a Wi-Fi passphrase change at first login alongside the admin one. The test asserts it is written only when the default is applied. --------- Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * docs(packaging): record the decommission plan for the module's own package build (#914) Freeze the intent, not yet the change: FreedomTechFeed/packages/net/tollgate-wrt/Makefile is the single canonical shipping package; this repo's packaging/Makefile is test/reproducibility only. packaging/README.md lists exactly what the feed consumes (packaging/files/) and the mechanical removal (Package/Build targets, build-ipk/local-build-ipk/build-env helpers, the build-package.yml publish jobs) to execute after the v0.6.0-rc4 release. Draft PR; merged post-release. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * test(harness): pin the env seams the setup script now resolves (release-check green) (#915) Three uci-defaults suites were red at release-check (they are NOT in the per-PR test.yml lane, so #913 merged with them red): - entry-ui-mapping (9): the suite's sed predated the TOLLGATE_ENTRY_UI_CONFIG env override, so the copied 99 read the HOST's live /etc/tollgate/config.json (the harness rot #899 describes). - admin-tls-identity (6) + trusted-entry-80 (1): the new private-WiFi provisional marker defaults to an absolute path the suites did not redirect. Harness-only: pin TOLLGATE_ENTRY_UI_CONFIG + ADMIN/PRIVATE_WIFI_PROVISIONAL_MARKER into each sandbox. NO change to 99-tollgate-setup (the risky half of #899). Sweep: all tests/packaging/* + tests/uci-defaults-* green. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * packaging: re-check the private subnet once the uplink is up (#926) setup_private_network derives network.private (lan/24 ±1) at uci-defaults time, before a fresh DHCP WAN necessarily has a lease, so an upstream that lands on the same /24 as the derived private network — the observed 192.168.2.1 against a 192.168.2.0/24 upstream — is missed. The router then answers for the upstream gateway's own address and DNS breaks on the deployed router. Add recheck_private_subnet (no-op unless the private /24 now overlaps a known upstream) and call it from the postinst AFTER `/etc/init.d/network restart`, reloading the network only when the private subnet had to move. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * fix(tollwallet): bound each mint registration so a slow mint cannot stall boot (#940) * fix(tollwallet): bound each mint registration so a slow mint cannot stall boot The wallet already registers only the mints the startup probe found reachable, but gonuts' AddMint does its own network I/O with NO http.Client timeout, so a mint that accepts the connection and never answers can stall the whole wallet construction — the module's own notes measure 113-347 s for 7 mints. That outlasts the installer's health window and made a healthy install report 'the merchant is degraded'. registerMint now gates AddMint behind a bounded GET /v1/keysets with OUR OWN client (8 s). A mint that does not answer is left UNREGISTERED rather than blocking boot: ensureMintRegistered retries it on first use and the reachable-set callbacks admit it when it recovers. Registration stays sequential, so gonuts is never called concurrently. Test: TestMintServesKeysets (serves keysets -> accepted; empty -> rejected; outruns the bound -> rejected). tollwallet + merchant modules green. * review: reword the bound's guarantee, state the residual, add a defer->recover test Addresses the independent review on the fork PR: - The comment no longer claims AddMint 'cannot hang'. The probe and AddMint are separate exchanges, so the bound makes a hang UNLIKELY rather than impossible; the real closure (a goroutine-with-timeout around AddMint, or registration off the boot path) is noted as a follow-up. - States the residual worst case explicitly: one registerMintKeysetTimeout per reachable mint, sequentially — 7 mints x 8 s = 56 s at boot — plus the new first-use latency for a deferred mint (a bounded <=8 s inline probe on the first payment routed to it; latency, not correctness). - Adds TestRegisterMintDefersThenRecovers: a mint DOWN at boot is left unregistered, and ensureMintRegistered (the path Receive takes) registers it once it recovers — pinning the integration claim through the real gonuts wallet, not just the probe unit. --------- Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * docs(operator-guide): fix the entry_ui=luci TLS-port attribution (#939) The SSL-command scope paragraph paired LuCI's :8443 with entry_ui=luci, inverted: under entry_ui=luci LuCI (uhttpd.main) owns the entry pair TLS port :443 and the board owns :8443. Both ports named in the old sentence belong to the entry_ui=board mapping. Ground truth: packaging/files/etc/uci-defaults/99-tollgate-setup port functions and docs/access-urls.md's mapping table. Found by the PR #744 review; one-line fix-forward. * docs: defer loopback rate-limit vs startup-gate follow-up (post-release) (#953) Record the root cause behind the 2026-10-16 'Setup failed' incident: the installer's own 3s poll exceeds RateLimitMiddleware's 10 req/min for 127.0.0.1, and the limiter runs before requireStarted, so the probe can get 429 {'error':'rate limit exceeded'} instead of the readable 503 {'code':'starting'} refusal. The installer side is worked around in rc33. The root-cause fix (exempt loopback from the limiter) is DEFERRED until after the current release — no new module RC in this line. Co-authored-by: c03rad0r <c03rad0r@users.noreply.github.com> * fix(upstream): select and blacklist upstreams by BSSID, not SSID (#910) * fix(upstream): select and blacklist upstreams by BSSID, not SSID One SSID can be served by several BSSIDs across different upstream networks — some routed to the internet, some captive or isolated. The manager keyed its blacklist by SSID and never pinned the scanned BSSID on the candidate, so a router that settled on a BSSID with no internet blacklisted the whole SSID, including any working sibling, and could never recover. The ICMP-only connectivity check could not tell "gateway but no internet" from "ok" either. - Blacklist by BSSID (falling back to the SSID only when it is unknown), skip candidates by BSSID, and blacklist the exact failed BSSID on an emergency switch. A sibling BSSID of the same SSID stays eligible. - Thread the candidate BSSID through SwitchUpstream and pin wireless.<iface>.bssid, clearing a stale pin when the candidate carries none. Manual CLI connect pins the strongest scanned BSSID for the SSID. - Replace the ICMP-only probe with a layered one — default route, DNS resolution, HTTP 204 captive-portal check — returning a reason (no-route/no-dns/captive/no-http). Used by the startup check, the main loop and post-switch verification. Tests: two-BSSID/one-SSID blacklist isolation, BSSID pin set/clear, and probe classification. make go-battery green across all 16 modules. * changelog: fragment for #910 (BSSID-aware upstream selection) --------- Co-authored-by: c03rad0r <c03rad0r@users.noreply.github.com> * ci(router-test): TBMG Trust Pipeline + bench lanes (dual twin) (#898) * ci(router-test): TBMG Trust Pipeline on the self-hosted runner + verdict comment Router-test is the TBMG Trust Pipeline entry point. Two changes: * Run the gateway dispatch on the self-hosted runner (role 86-tbmg-runner on hermes-nvme) over the private fips mesh with its dedicated per-machine key — no per-repo secrets and no metered LAN traffic. * Add a report job that posts ONE idempotent verdict comment per PR (marker <!-- tbmg-trust -->), so every PR shows whether it is ready to merge. Fork PRs (which must never run on a self-hosted runner) are reported as "not tested" instead of silently skipped. The security contract is preserved: PR -> isolated lab; bench only on main or an explicit dispatch behind bench-hardware. * docs(changelog): fragment for the TBMG Trust Pipeline (#898) * ci(router-test): dispatch the bench lanes on the gateway Add a gated `bench` job (non-PR, bench-hardware) that SSHes to the elected router-bench-gateway over fips and runs scripts/bench/bench-lanes.sh — the lane orchestrator (clean flash -> install -> smoke -> radios OFF, JSON summary). * ci(ngit): mirror the router-test trust pipeline into the ngit twin Land the trust pipeline in the ngit execution twin too (dual setup): same plan/router-test/bench jobs, ngit-safe (no GitHub `environment`/`vars`; the verdict is published by ngit-ci to Nostr, so the report job just summarises). --------- Co-authored-by: felixfelix-bot <felixfelix-bot@users.noreply.github.com> * feat(wireless): wire the vendor-IE price producer the scan boundary staged for (#959) * feat(wireless): wire the vendor-IE price producer the scan boundary staged for PricePerStep/StepSize were declared across four data models (NetworkInfo, Gateway, DiscoveryEntry, KnownTollGate) and had no producer: scanner.go hard-zeroed them with a 'STAGED (this PR)' comment, gated behind the VendorIEDiscovery flag whose consumer did not exist. Wire it: - scanner.go: capture the raw IE blob from the scan's 'IE: <hex>' line and route network assembly through a new buildNetworkInfo(), which calls the existing ParseVendorIEsFromScanData() to fill IsTollGate / RawIEs and the pricing fields. Remove the staged comment. - vendor_element_manager.go: add pricing TLVs (0x03 price_per_step, 0x04 step_size, both 2-byte big-endian) to the encoder and parser, so the IE can carry a price hint. - types.go: TollGateAdvertisement gains PricePerStep/StepSize. Authority is unchanged and now documented in-code: the IE is an UNSIGNED hint via OUI identification that selects a candidate for the signed kind-10021 probe on :2121, which remains the authority for price. A zero pricing value means 'not advertised here', never 'free'. Pricing TLVs are dropped (truncated=true) rather than overflowing the 255-byte element or pushing out the pubkey, because identification outranks the price hint. Tests (observed RED before the change, GREEN after): - TestParseIwinfoOutput_PopulatesVendorIEFields - TestParseIwinfoOutput_NonTollGateIEIgnored - TestParseIwinfoOutput_TollGateIEWithoutPricingTLVs - TestParseIwinfoOutput_NoIEMeansNotTollGate - TestParseIwinfoOutput_MultipleNetworksAndIEs - TestVendorIE_RoundTripPricing (+4 subtests) - TestVendorIE_PricingDroppedRatherThanOverflow Full wireless_gateway_manager suite green, including the five pre-existing vendor-IE tests (no regressions). * fix(wireless): parse the vendor element from the format the router actually emits Hardware verification on tollgate-A04E (2026-10-16) falsified the first implementation's assumption. Measured: - `iwinfo <dev> scan` prints NO information elements at all (grep -c 'IE:' returned 0), so it can never yield IsTollGate via the vendor element. It does report hidden APs, as `ESSID: "unknown"` — a literal placeholder. - `iw dev <dev> scan` is the ONLY element source, and it prints elements DECODED into named fields (SSID:, RSN:, HT capabilities:, ...), not as a hex blob. A vendor element appears as `Vendor specific: <colon-hex>`. - iw strips the element ID (0xDD) and the length byte from that body. Feeding the body straight to ParseVendorIEsFromScanData returns 0 elements: measured as 36 good bytes in, 0 advertisements out. The body must be re-framed with its 0xDD + length prefix. Change: - ParseIwinfoOutput now accepts BOTH real formats: iwinfo `Cell NN - Address:` records and iw `BSS <mac>(on ...)` records, per-record, so a stream carrying either parses. - SSID parsing handles both keys; RSN supplies encryption for iw output (it has no `Encryption:` line); `freq:` supplies the channel. - The vendor element is re-framed before parsing. - Placeholder SSIDs ("", "(hidden)", "unknown") are refused admission, so a literal cannot become a selectable upstream. Tests rewritten against the measured format: TestParseIWScanOutput_{PopulatesVendorIEFields,NonTollGateVendorIgnored, TollGateWithoutPricingTLVs,NoVendorElementMeansNotTollGate, RealHardwareHiddenSSID,MultipleNetworksAndVendorElements}. Three failed against the raw-body bug and pass with the re-frame; the full wireless_gateway_manager suite is green with no regressions. * fix(wireless): read VendorIEDiscovery from UpstreamWifi; scan via iw when enabled Build fix: the flag lives on the UpstreamWifi sub-struct, not on Config. With that wired, the scan path can finally populate the fields the vendor element carries. Two facts measured on tollgate-A04E (2026-10-16) drive the design: 1. `iwinfo <dev> scan` prints NO information elements, so it can never identify a TollGate by vendor element. It is kept because it reports hidden APs (as ESSID "unknown") that iw can omit. 2. `iw dev <dev> scan` is the only element source. When vendor-IE discovery is enabled, scanRadio runs it as well and mergeScanResults folds the two views by BSSID, letting the element-derived fields (IsTollGate, RawIEs, price, step) come from iw while iwinfo still contributes entries iw lacks. iw is best-effort: a failure or empty result keeps the iwinfo view rather than failing the scan. * test(wireless): cover mergeScanResults, scanRadioWithIW and the flag wiring Coverage on the newly added code was incomplete: mergeScanResults and scanRadioWithIW were both at 0%, and parseColonHex at 75%. The merge is the join that lets element-derived fields come from iw while iwinfo-only entries (notably hidden APs) survive, so an untested merge was the largest risk in this change. Added: - mergeScanResults: enriches an existing BSSID from the second view; keeps entries only one view saw; fills gaps (signal/encryption); is the identity when the second view is empty. - scanRadioWithIW: a failed iw invocation returns nil rather than propagating an error, so the caller keeps the iwinfo view. - NewScanner/SetVendorIEDiscovery wiring. Also moves the on-router demo to src/cmd/tg-vendor-ie-demo, so it compiles against the repo's own module graph (the nested scanner module is reached via the existing replace directives) instead of a hand-rolled go.mod. Coverage on the new surface is now: mergeScanResults 100%, appendScanEntry 100%, NewScanner 100%, SetVendorIEDiscovery 100%, ParseIwinfoOutput 98.6%, buildNetworkInfo 92.9%, scanRadioWithIW 70%, parseColonHex 75%. * docs(wireless): record the cross-build target defect found on hardware The demo binary was built with GOOS=linux GOARCH=arm64 but the router rejected it with 'syntax error: unexpected end of file'. Its ELF header read 02 00 3e 00 (e_machine 0x3e = x86-64) instead of 02 00 b7 00 (0xb7 = aarch64). The sha256 matched the local file exactly, so the transfer was correct — the build target was wrong. Adding CGO_ENABLED=0 produced a proper statically linked aarch64 binary. The check that would have caught it in one line, now recorded for reuse: head -c 20 <binary> | od -An -tx1 | tail -1 # want '02 00 b7 00' for aarch64 Size and sha256 are NOT evidence of the right architecture; both matched while the binary was unrunnable on the target. * feat(demo): add --file mode and record the on-router demo evidence The demo could only run 'iw dev <dev> scan' live. Where no neighbour running this build exists (as on tollgate-A04E), that can only ever show the ABSENCE of a vendor element, never the positive identification path. Adding '--file <path>' lets the parser be run against a saved scan, so a synthetic element can be injected into REAL captured router output. Evidence from the live MT6000 (GL.iNet GL-MT6000, OpenWrt 24.10.2, aarch64_cortex-a53), 2026-10-16: A. live scan: iw dev phy0-ap0 scan: 28736 bytes, err=<nil> Vendor specific: lines present = 0 parsed networks = 12 identified TollGates = 0 (correct: no neighbour runs this build) B. real scan + one injected TollGate element: read /tmp/injected-scan.txt: 35168 bytes Vendor specific: lines present = 1 parsed networks = 16 TOLLGATE bssid=b2:36:4a:2a:03:32 ssid="StarGate" signal=0 price_per_step=7 step_size=1000 rawIE_bytes=38 identified TollGates = 1 -> the parser located the element inside genuine scan data and recovered the encoded price/step exactly. The demo binary was cross-built with CGO_ENABLED=0 and its ELF machine field verified as 0xb7 (aarch64) BEFORE transfer — the check whose absence produced the unrunnable x86-64 binary recorded in the previous commit. * fix(wireless): emit pricing before the pubkey so an oversized pubkey fails loudly Defect found by a cold cross-family review (deepseek-flash, PR #959, 2026-10-16). With the original ordering a pubkey could pass the fit check and land the body at 253-255 bytes; the pricing TLVs were then appended AFTER it, the 4-byte guard fired, and the pricing was silently dropped while the element still reported success. A router whose pubkey drifted large would appear to advertise no price — indistinguishable from a router that advertises none. Fix: emit pricing BEFORE the pubkey. An oversized pubkey now fails the existing loud error path ('vendor IE body too long') instead of silently losing the price. The length octet is also clamped explicitly with truncated=true, so a wrap can never produce a malformed element silently. Tests (observed RED against the original ordering, GREEN after): - TestEncode_PricingNotDroppedByPubkey (fails: 'pubkey 239 bytes cannot fit') - TestEncode_PricingSurvivesWithMaxFitPubkey (loud error for oversized pubkey) - TestEncode_LengthOctetNeverWraps (octet == body length) Encoder coverage 87.2%. Full wireless_gateway_manager suite green; go vet clean. MOUNTED BINARY VERIFIED: demo rebuilt for the router and its ELF machine field checked as 02 00 b7 00 (aarch64) before any transfer. Credit: this defect was surfaced by the cross-family lane, not by me. * refactor(wireless): rename the scan-side price hint so it cannot be confused with the authoritative one Follow-up from a cold cross-family review of PR #959 (deepseek lane, nit Q5). NetworkInfo.PricePerStep shared a name with PricingOption.PricePerStep, which IS authoritative (built only from a signature-verified kind-10021 event, and the only field the money path spends). The shared name is what caused the v1 review to raise a false blocker: from the excerpt it could not tell which field was authoritative. Renamed on the scan/display side only: NetworkInfo.PricePerStep -> NetworkInfo.AdvertisedPricePerStep NetworkInfo.StepSize -> NetworkInfo.AdvertisedStepSize DiscoveryEntry / KnownTollGate / cli.UpstreamNetwork likewise. NOT renamed (deliberately, the authoritative/money-side fields): config_manager.MintConfig.PricePerStep tollgate_protocol.PricingOption.PricePerStep TollGateAdvertisement.PricePerStep (the IE encoder's own field) JSON wire keys are UNCHANGED (price_per_step, step_size) - a documented operator-facing contract (docs/operator-guide.md) - so this is a Go identifier change only and is wire-compatible. The field comment now states the authority boundary explicitly, so the next reader does not have to reconstruct validator.go -> session.go to learn that a zero here means "not advertised", never "free". Verified: whole tree builds, go vet clean, wireless_gateway_manager and cli test suites green, no NetworkInfo-side references to the old names remain, and the money path is untouched. * chore: keep session scratch out of the PR PR-BODY.md, PROGRESS.md and build-review-prompt*.py are this session's working artifacts (the PR description and the review prompts), not deliverables. They were swept in by 'git add -A'. Untracked and gitignored; the files stay on disk. Leaving them in would put review-harness scratch in an upstream PR diff. --------- Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * feat(wireless): make the vendor-IE emitter reachable (#960) * feat(wireless): make the vendor-IE emitter reachable, preserving foreign elements STACKED on #959 (pr/vendor-ie-price-producer). The encoder's pricing TLVs exist only there, so this cannot stand alone; do not merge before #959. #959 wired the READ path (scan -> IsTollGate + pricing). Nobody had wired the WRITE path, so no router could advertise itself and the read path had nothing to find: EmitTollGateVendorIE had ZERO callers (dead code) GetLocalAPVendorElements was a stub returning an empty map Hostapd set_vendor_elements REPLACES the whole element set, and the old implementation sent only its own hex Three defects fixed: 1. DEAD CODE. The emitter had no caller and the reader was a stub, so the round-trip (what did I advertise?) could not be verified at all. The reader now reports the last successfully applied set. Documented limitation: this is the processor's own record, NOT a hostapd read-back, because OpenWrt's hostapd exposes set_vendor_elements with no getter. 2. CLOBBERING. set_vendor_elements replaces the entire set, so writing only our own element would WIPE co-resident vendor elements (11k/11v, mesh, other tooling) on every update. Foreign elements observed on the AP are preserved and re-sent alongside ours. Payload ordering is sorted so the same element set always serialises identically. 3. SILENT FAILURE. The old code returned nil even when every hostapd call failed, so a router could fail to advertise and report success - indistinguishable from a router with nothing to say. All-calls-failed is now an error; no-hostapd (radio down) remains a benign no-op. Gating: NewVendorElementProcessor defaults to DISABLED and EmitTollGateVendorIE is a no-op when disabled, so merging this changes nothing until an operator sets vendor_ie_discovery=true. Enforced by tests in both directions. Test seam: added Connector.runUbus, the same injection pattern already used for runUCI (#817), so the emitter's tests exercise the real code path. Tests (observed RED first - the seam and fields did not exist): TestGetLocalAPVendorElements_RoundTripsWhatWasSet TestSetLocalAPVendorElements_PreservesForeignElements TestSetLocalAPVendorElements_FailsLoudWhenAllCallsFail TestSetLocalAPVendorElements_NoHostapdIsNotAnError TestEmitTollGateVendorIE_SendsEncodedElement TestEmitTollGateVendorIE_GatedOffDoesNothing TestNewVendorElementProcessor_DefaultsToDisabled / _EnabledWhenAsked TestVendorElementProcessor_DisabledWritesNothing / _EnabledWrites TestSetLocalAPVendorElements_PayloadIsDeterministic Coverage: SetLocalAPVendorElements 93.9%, GetLocalAPVendorElements 100%, NewVendorElementProcessor 100%, EmitTollGateVendorIE 77.8%. NOT in this PR, stated so the gap is not mistaken for done: the daemon does not yet CALL the emitter on price change or at startup. Doing so needs a re-apply-on-restart story (hostapd loses runtime-only vendor elements), which is its own change. Until that lands, this PR makes the emitter correct and reachable, not automatic. * fix(wireless): make foreign-element preservation real, not theatre Fixes from a cold cross-family review of this PR (deepseek lane, 1 critical + 2 major). All three were cases where the code looked right and the tests passed because the tests supplied the very input production never provided. CRITICAL - preservation was theatre. foreignElements was populated ONLY by tests; nothing in production ever filled it, so the merge loop iterated an empty map and the first write replaced whatever was on the AP. The comment claimed a property the code did not have - the same defect class as the dead-code emitter this PR fixes. Now loadConfiguredVendorElements() populates it from the uci wireless config (where a persisted vendor_elements option lives), skipping our own OUI so it is not duplicated. Verified by mutation: removing the load call makes the new test fail with the clobbering payload '{"vendor_elements":"dd06212121"}' and nothing else. HONEST LIMITATION, now in the field comment: hostapd exposes set_vendor_elements with NO getter, so runtime-only elements CANNOT be read back and nothing in this package can discover them. A caller that knows them must supply them. This is stated rather than papered over. MAJOR - ubus failure looked like "no hostapd". A failed `ubus list` returned nil, making a broken ubus indistinguishable from a healthy router whose radio is off, so a failure to advertise was invisible. It is now an error. MAJOR - partial failure was reported as success. Advertising on 1 of 2 APs returned nil, so a router advertising on ZERO APs could still look fine. A partial result is now surfaced; full failure still errors; no-hostapd remains a benign no-op. MAJOR - the getter under-reported. It recorded only OUR elements, omitting the preserved foreign ones that are equally on the air. It now records the merged set actually sent. REFUTED (not acted on): the review's Q2 claimed the disabled gate is only checked in the emitter so a direct SetLocalAPVendorElements call bypasses it. The guard IS the first statement of SetLocalAPVendorElements. Verified by reading and by the existing TestVendorElementProcessor_DisabledWritesNothing, which asserts zero ubus calls. Recorded here because a refutation is evidence too. Tests (each fails without its fix): TestSetLocalAPVendorElements_PreservesElementsFromUCI (production path only) TestLoadConfiguredVendorElements_DoesNotCaptureOurOwn TestSetLocalAPVendorElements_UbusListFailureIsAnError TestSetLocalAPVendorElements_PartialFailureIsReported TestGetLocalAPVendorElements_ReportsMergedSet TestLoadConfiguredVendorElements_UCIFailureIsTolerated Full wireless_gateway_manager suite green. Credit: all three real defects were found by the cross-family lane; I shipped them. --------- Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * feat(mptcp): multi-WAN bonding integration seam (#927) * feat(mptcp): multi-WAN bonding integration seam for TollGate Adds src/mptcp_bonding, the validated core of an MPTCP multi-WAN bond as an *uplink* concern. Portal, Cashu payment, session accounting and merchant mode are untouched: bonding replaces only how packets leave the box. The package is deliberately pure (no syscalls, no file I/O) so the steering decision lives in one testable place, shared by both the generated firewall rule and any Go caller. policy defaults to guest_only so the box never diverts its own management traffic into the transparent proxy. A naive divert during bring-up cost an operator shell access to a field device. Validation is strict: a half-configured bond silently changes how a paying customer's traffic leaves the device. It therefore rejects fewer than two uplinks, a malformed server or port, guest_only without a parseable CIDR, colliding socks/tproxy ports, duplicate uplinks, and an unparseable fwmark. enabled=false validates with nothing else set. Tests: 10 tests / 32 cases, 92.1 percent coverage, go vet clean, gofmt clean. Written test-first: with the implementation removed the suite fails to build (undefined Config and DefaultSubflows). docs/MPTCP-BONDING.md records what is proven on hardware (MP_JOIN captured on the wire, subflows:2 live, MPTCP verified end-to-end) and what is not: mid-transfer failover is untested, and aggregation is roughly 1.2 to 1.3 times, not 2 times. * feat(mptcp): render MPTCP endpoint + limits provisioning Adds the node-side kernel configuration the bond needs, as pure renderers so the plan is unit-testable without a router: EndpointPlan() one `ip mptcp endpoint add <addr> dev <iface> signal subflow` per uplink with a known address LimitsSetCommand() `ip mptcp limits set subflow N add_addr_accepted N`, raising the subflow ceiling to the uplink count but never lowering it below PrerequisitePackages() names ip-full Both renderers return nothing when the bond is disabled, so a device that has not opted in emits no commands. These pin three failures the lab found, all of which are silent on device: * a `subflow`-only endpoint never sends ADD_ADDR, so the peer never learns the address and no second subflow can form. It looks configured and does nothing. Every endpoint must be `signal subflow`. * the kernel default of 2 subflows is below what a multi-WAN box needs; the extra uplinks would be configured but never used. * BusyBox `ip` has no `mptcp` subcommand, so ip-full is required or every command fails with a usage error on a stock image. Docs record the FIPS-side change this depends on: enabling MPTCP on the node cannot make the socket multipath-capable, because FIPS creates its own socket. The transport patch is carried on the fork at felixfelix-bot/fips @ pr/tcp-mptcp-option (documented, not PR'd upstream pending the aggregation measurement). Tests: 8 new, all RED first. Package coverage 90.2%. go vet and gofmt clean. Refs: docs/MPTCP-BONDING.md * docs(mptcp): lab measurement - subflow formation proven, aggregation topology-limited Records the first successful two-disjoint-path MPTCP measurement on the GL-MT6000 lab router (tollgate-A04E, OpenWrt 24.10.2). Subflow formation is PROVEN. With both ends configured `signal subflow`, a real second subflow forms and completes its handshake: MPJoinSynRx 0 -> 2 (JOINs received) MPJoinAckRx 0 -> 2 (handshakes completed) ss -Min: subflows:1 subflows_total:2 local_addr_used:3 That counter stayed at 0 in every earlier run; the `signal` flag is what unblocked it. Aggregation is NOT achieved, and the reason is topological rather than a defect in this code. Measured on two disjoint L2 segments that both terminate at the same router: single path (--bind-dev) 925-928 Mbit/s unbound (MPTCP free to use both) 919-921 Mbit/s No gain. Conntrack shows only one of the two MPTCP connections carrying data: both subflows converge at one router with one upstream, so the scheduler keeps traffic on the better subflow rather than splitting across a shared bottleneck. Stated plainly so the docs do not overclaim: subflow formation yes, throughput aggregation no, and independent end-to-end paths are required to demonstrate the latter. Refs: docs/MPTCP-MEASUREMENT.md * chore(mptcp): track PROGRESS.md for cross-iteration resumability Records iteration 1 findings so a capped-out or re-dispatched worker can resume without re-running the lab work: * the lab DOES have two disjoint L2 paths (supersedes the earlier single-lin…
…s) (#1091) * test(merchant): late-receive outcome selection raced the owed-grant ERROR line — pick the COMPLETED record by name (#681 red herring) (#718) * test(merchant): duplicate-guard harness needs an ndsctl on PATH — install the renewal fake (#822) (#835) * test(merchant): FiredOnce pinned the opposite of the wired contract — assert fire-exactly-once deterministically (#732) (#836) * test(contract): SSID matcher fence — pinned acceptance table for hasTollGateSSID (#618/#706 classes) (#839) The reader twin of check-ssid-format.sh: what the WRITER may emit is pinned there, what the MATCHER must accept is pinned here. One fixture table (tests/contract/ssid-naming-fixtures.txt) drives both the Go unit test and tests/contract/check-ssid-naming.sh (structural: table covers every required class and the Go pin is wired; behavioural: the real go test runs over the table). Classes: both brands bare (#618 — the matcher was brand-blind and a whitelabel upstream was invisible), case- insensitive (lowercase installer-era SSIDs still in the fleet), single-'!'-decorated with double-'!' rejected (#706). Lands #706's reader-side tolerance (trim at most one '!') so the table is green on merge; #706 then only needs its writer/installer half. The whitelabel fixtures carry the brand literal in the table's file — the SSID contract's allowlisted home in the rebrand gutter (same exemption as check-ssid-format.sh); Go source keeps assembling it from non-contiguous bytes. Negative control: a planted contradictory fixture line turns the fence red (behavioural layer), removed again. Refs #767 Co-authored-by: Amperstrand <dev@amperstrand.no> Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * test(merchant): closeGateCleanup clears an abandoned close streak — the streak outlived the test that spent it (#841) (#842) * fix(wallet,merchant): correlate NUT-07 answers by value; fsync journal directories after rename (#834) (#876) * fix(wallet,merchant): correlate NUT-07 answers by value; fsync journal directories after rename (#834) Two audit-hardening halves of #834: - CheckTokenSpent counted answered states but never correlated each response Y back to a requested one. It now correlates by value: an answer about a Y we did not ask about, or a duplicate answer padding a short reply, is an error instead of a silent verdict — the #502 pending-intent reconciliation (abandoned vs owed) decides on this evidence, and NUT-07 requires states to match the request. - the three money journals (receive intents, owed grants, quote store) fsynced the temp file but not the directory after rename; on ext4-class filesystems the rename itself is only durable after the directory entry is synced, so a power cut could drop the newest journal write whose contents were already fsynced. syncParentDir closes the gap after every rename, surfacing a failed dir fsync as a save error (the record must be treated as not-yet-durable, exactly like a failed temp Sync). * changelog: fragment for #840 (NUT-07 value correlation + journal dir fsyncs) * test(tollwallet): pin the NUT-07 shortfall refusal — direct coverage for the answered-N-of-M branch --------- Co-authored-by: Amperstrand <dev@amperstrand.no> Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * fix(deps): the gonuts pin is one version, everywhere — manifest truth, glob fence, mechanical bump (#791) (#798) The wallet fork's pin drifted three ways in one release week: a hand bump moved four carriers and left a fifth (scripts/token-recovery) three minors behind; two open PRs pinned the same lines to a tag and a pseudo-version; a newer fork tag carrying an open-P2 fix went unnoticed. - packaging/build-inputs.json names the pin (.gonuts.version) as the single source of truth, beside the go and portal pins. - tests/contract/check-gonuts-pin.py discovers carriers by glob (both go.mod require shapes — the single-line form hid the token-recovery stale pin from the checker's first draft) and refuses any disagreement, offline, in pre-commit and both CI twins; --latest (release-check only) refuses a release while the fork has a newer stable tag. - tests/contract/gonuts-pin_test.sh: 7 planted-drift verdicts. - scripts/bump-gonuts.sh rewrites manifest and every carrier together. - scripts/token-recovery raised v0.10.0 -> v0.13.0 (the fence's first catch; builds and vets clean against the new pin). - AGENTS.md states the rule and the check, never a carrier count. Co-authored-by: pr-train-lane <lane@opencode> * fix(packaging): allow pre-auth DNS on UDP, not only TCP — users_to_router udp/53 (#749) (#769) The nodogsplash pre-auth allow list carried tcp/53 (the feed default) but not udp/53, and stub resolvers query UDP first: the ndsRTR chain dropped the query, the client burned a ~5s timeout per attempt before any TCP fallback, and hostname resolution from a captive client was dead in practice — while the portal page itself is usually reached by name (local-lab campaign behind PRTA PR #101). assert_nodogsplash_allow_entries now asserts BOTH protocols, with a new protocol-aware guard (uci_list_has_entry): uci_list_has_port matches a port on either protocol, so guarding udp/53 with it would see the feed's tcp/53 entry and skip the add. tcp/53 is asserted alongside (not left to the feed default) because the writer's contract is that a stale or absent list is repaired on every setup path. tests/uci-defaults-dns-preauth_test.sh pins the matrix against the shipped writer via the fake-uci harness: feed-default list gains udp/53 without duplicating tcp/53, a stale list is repaired on both protocols, the guard is proven protocol-aware (udp present + tcp missing still adds tcp), reruns add nothing, and uci's quoted single-line rendering is handled. The same-version allowlist and convergence suites carry the DNS entries through their full-script runs; CI runs the new suite on every push. Fixes #749 * chore(packaging): pin the portal at the merged network-settings revision (#766) The portal pin moves to 6c25834c (OpenTollGate/tollgate-captive-portal-site#66, merged) — the board half of the operator settings whose module half landed in #604: the Settings cards for the private network's credentials and the administration scope, and the WiFi page's private-radio edit routed through `tollgate config_set` with the section-name contract pinned (both `wireless.private_radio0/1` names and the structural AP-on-`private` signal, read in the netifd `config`-nested shape and the flat fallback) and the half-applied save named rather than swallowed. The gate trail is on the PR: rounds 1-3 CHANGES-REQUESTED, round 4 flagged the structural net reading the wrong shape, round 5 delta verification APPROVED. Evidence: 23/23 Playwright specs across the three admin suites, 4/4 unit tests, tsc clean, packaging guards and the anti-leak gate green. The .secrets.baseline entry for the pin line moves with it, per the baseline's documented purpose of recording reviewed pins: a pin whose hash is not recorded is (correctly) a new alert. As with #714, the pin is the atomicity boundary: the module's rc1 changelog entry already says these settings are settable "on the board" — only a release built after this commit actually carries the board half. Co-authored-by: Amperstrand <amperstrand@localhost> * fix(cloud-lab): Dockerfile.client's GO_VERSION must be a global ARG — the client image cannot build from scratch (#844) * fix(cloud-lab): Dockerfile.client's GO_VERSION must be a GLOBAL arg — the client image cannot build from scratch Dockerfile spec: only args declared before the FIRST FROM are visible to FROM lines. Dockerfile.client declared ARG GO_VERSION=1.26.8 inside the rust-builder stage (after FROM rust:1.88-bookworm) and then used it in the go-builder stage's FROM — where it resolves empty, so every from-scratch build of the client/killer image fails: failed to solve: failed to parse stage name "golang:-bookworm": invalid reference format Introduced by #687's toolchain-parity sweep, which added the ARG to both cloud-lab Dockerfiles — correctly placed in Dockerfile.tollgate (before its first FROM), mid-file in Dockerfile.client. Nothing caught it because the org CI that builds these images has been dark and local lanes ran on cached images. Move the ARG above the first FROM. Verified on the rig (ai-legion, docker 29.1.3/compose 2.40): with this exact change the killer image builds and the #497 crash-injection lane runs to completion on #793's head — pending swap recovered, post-recovery payment 200 (transcript: /root/src/rel-793/transcript-stock.txt on ai-legion). * docs(changelog): fragment for the cloud-lab client global-ARG fix * chore: re-sync the PR head after auto-merge churn (no content change) --------- Co-authored-by: Amperstrand <amperstrand@users.noreply.github.com> Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * fix(merchant): receiveTimeout as per-merchant seam — the package var raced leaked PurchaseSession readers (#821) (#837) * fix(setup): never print generated credentials; add TOLLGATE_PRIVATE_WIFI_PASSWORD (#850) * fix(setup): never print the generated admin/WiFi credentials; add TOLLGATE_PRIVATE_WIFI_PASSWORD * docs(changelog): fragment for the no-print credential change * fix(wireless_gateway_manager): upstream connect refreshes credentials and clears competing STAs (#817) (#848) * fix(wireless_gateway_manager): upstream connect refreshes credentials and clears competing STAs (#817) Two defects chained into the desk-run failure: Reuse: FindOrCreateSTAForSSID returned a disabled STA section for the SSID without writing the new passphrase — a rotated password was dropped, the join failed WRONG_KEY, and the flow reported a DHCP wait. The reuse path now rewrites encryption, key (deleted for open networks) and network=wwan before returning. Exclusivity: mt76 exposes ONE STA netdev per radio, so any other enabled STA section on the target radio keeps the netdev and its (possibly stale or foreign — the desk run's tollgate_uplink) key is what wpa_supplicant joins with while the fresh candidate never gets an interface. SwitchUpstream now disables every enabled STA on the candidate radio, not just the section GetActiveSTA named; the auto-switcher in upstream_manager inherits the same guarantee. Tests drive the real Connector through an in-memory uci fake (the mock-level tests asserted canned returns and never touched this logic); all three new tests are planted-drift-verified to fail on the pre-fix code. * fix(wireless_gateway_manager): address cross-family review — log the stale-key delete, note the revert side effect Two non-blocking findings from #848's review: the open-network transition's key delete now logs failures at debug (the delete is the safeguard itself; an error was silent), and SwitchUpstream's comment states that a reverted switch deliberately leaves competing STAs disabled — the stale key must not regain the netdev — pinned by the revert test. * fix(wireless_gateway_manager): map bare 'WPA PSK' scans to psk-mixed iwinfo reports 'WPA PSK (CCMP)' both for genuine WPA1 networks and for mixed/WPA2-preferring APs whose beacon leads with the WPA1 IE — measured on the lab bench: such an AP rejects a psk section's WPA1 auth and only completes WPA2, so the connect flow wrote a section that could never handshake and reported a DHCP wait (#817's second root cause, same operator-visible symptom as the garbage-key defect). psk-mixed negotiates either way; regression test pins the measured AP shapes. * test(wireless_gateway_manager): pin the psk-mixed mapping in the legacy DetectEncryption table The pre-existing table asserted the old WPA1 mapping for 'WPA PSK (TKIP)'; 0e3da8f deliberately maps that shape to psk-mixed (iwinfo prints the same string for genuine WPA1 and for WPA2-preferring APs whose beacon leads with the WPA1 IE — measured on the bench; psk-mixed negotiates either). Caught by the full go-battery, which the -run filter had skipped via a non-matching test name. --------- Co-authored-by: Amperstrand <dev@amperstrand.no> * fix(setup): never generate a credential the operator cannot see (M1) (#896) * fix(setup): never generate a credential the operator cannot see M1: when root has no password and TOLLGATE_ADMIN_PASSWORD is unset, the package now generates NOTHING and refuses to serve the admin board, instead of creating a provisional credential. A generated-but-withheld password is exactly how routers got locked out (the installer deferred one credential while this package generated another; the operator held neither). The admin password must be provided: as TOLLGATE_ADMIN_PASSWORD at install time, or with `passwd root`, then the gate is re-run. The generate_admin_password/mark_admin_credential_provisional helpers are removed; a provisional marker carried over from an older install is still honoured so an upgraded router keeps telling the operator the credential was generated. * changelog: fragment for #884; fix #850 fragment format The #850 fragment started with a heading instead of a bullet, so changelog-assemble.py check failed on main. Reformatted it as a bullet. --------- Co-authored-by: Your Name <you@example.com> * setup: read the operator credential from a root-only file too (#897) apk-tools 3 (OpenWrt >= 25.12) does NOT forward the caller's environment to package scriptlets, so `TOLLGATE_ADMIN_PASSWORD='…' apk add` never reaches 99-tollgate-setup there: the module saw the env empty and refused the admin board (fail closed, no lockout, but no board). Proven on a GL-MT3000 running vanilla 25.12.5 / apk-tools 3.0.5. operator_chosen_admin_password now falls back to a root-only file (/etc/tollgate/admin-password); the private-WiFi passphrase likewise falls back to /etc/tollgate/wifi-password. Both are consumed (removed) once applied. The env still wins when present, so opkg (where the env IS forwarded) is unchanged. tests/packaging/admin-board-requires-credential_test.sh: +2 cases (staged file applies when the env is empty; env wins over the file). 80 passed, 0 failed. Co-authored-by: Your Name <you@example.com> * setup: never SET a credential — admin or private-WiFi (#884 follow-up) (#902) The package already stopped GENERATING an admin credential (#884), but it still SET one from the install environment (TOLLGATE_ADMIN_PASSWORD / TOLLGATE_PRIVATE_WIFI_PASSWORD) or a staged root-only file (/etc/tollgate/admin-password, /etc/tollgate/wifi-password). That is the same hazard one step over: an operator who pasted the run-book's EXAMPLE value verbatim ended up with root set to a string they never chose, with no link to it. Remove every credential write. 99-tollgate-setup: - drop TOLLGATE_ADMIN_PASSWORD / TOLLGATE_PRIVATE_WIFI_PASSWORD, ADMIN_PASSWORD_FILE / PRIVATE_WIFI_PASSWORD_FILE, operator_chosen_admin_password(), set_admin_password(), clear_admin_credential_provisional() and the generated private-WiFi PSK; - enforce_admin_credential now only ASCERTAINS: set/locked serve, empty/unknown refuse and drop the board, telling the operator to set one with `passwd root` (or LuCI) and re-run; - setup_private_network: preserve an existing passphrase, otherwise leave the private AP DISABLED (wired br-private is unaffected) until one is set; - keep reading the legacy admin-credential-provisional marker on upgrade. CLI: - `tollgate network private set-password` and the config applier now also set disabled=0 on the radio they write a passphrase onto, so setting one actually brings the (disabled-by-default) AP up. Tests: - admin-board-requires-credential: rewritten around the new invariant; ADD the regression that env AND file present leaves root byte-identical, passwd never invoked, the staged file unconsumed, and the board refused; - new tests/uci-defaults-private-credential_test.sh: no passphrase => no key, AP disabled; env/file ignored; existing key preserved+enabled; - new src/cli/network_setpassword_test.go: set-password and the applier enable. Docs: README + unreleased changelog fragments updated to match. Runs green in the module test tiers (only the pre-existing entry-ui-mapping_test.sh failures remain, identical to main). Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * chore: VERSION v0.6.0-rc2 (#903) Stamp the release candidate for the no-generated-credentials work. The feed's PKG_SOURCE_TAG is read from this file, so the built binary reports v0.6.0-rc2 to match the package version 0.6.0_rc2. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * rebase onto main (post-rc2 + #798 fence + credential series): builds: enforce the two-path doctrine — surface contract test, one-pass matrix split, rails (#713) Co-authored-by: Amperstrand <dev@amperstrand.no> * packaging: refuse to install when root has no password (preinst) + VERSION v0.6.0-rc3 (#907) The :8090 admin board's login is checked against root's /etc/shadow hash, and rpcd's session.login accepts ANY password while that hash is empty. The package never sets a credential, so a password-less install would leave an unauthenticatable board. Rather than install and then withhold the board, refuse BEFORE unpacking: the preinst exits non-zero on an empty root hash, so nothing is half-installed and the operator is told to `passwd root` and retry. - preinst (was a deliberate no-op): dependency-free (awk, no jq — the past jq-preinst-aborted-upgrades lesson), allows a locked root, bypasses on IPKG_INSTROOT (offline/image builds) and TOLLGATE_ALLOW_EMPTY_ROOT=1 (CI hatch). - new tests/packaging/preinst-refuses-empty-root_test.sh (9 checks). - VERSION -> v0.6.0-rc3. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * setup: default to a fixed TollGate credential instead of refusing (#913) * setup: default to a fixed TollGate credential instead of refusing The admin board's login is checked against root's /etc/shadow hash, and rpcd's session.login accepts ANY password while it is empty. rc3 refused to install in that state, but a failing pre-install is NON-FATAL under apk-tools 3 (the .apk landed anyway) and requiring `passwd root` before install burdened every operator. Replace the refusal with a FIXED default: 99-tollgate-setup sets root to TOLLGATE_DEFAULT_CREDENTIAL ("TollGate") when none exists, sets the private (management) WiFi key to the same value, marks the admin credential PROVISIONAL, and serves the board. The admin/config UI forces a change at first login (shipped separately, post-release). No install-time credential is accepted (TOLLGATE_ADMIN_PASSWORD / staged file stay ignored); an operator's own credential is preserved; the write still fails CLOSED if it cannot take. - preinst refusal -> no-op (the two lanes agree now). - new tests/packaging/admin-board-defaults-to-tollgate_test.sh (59 checks); preinst-refuses-empty-root -> preinst-never-refuses; the private-credential and configui-8090 suites updated to the default contract. - VERSION -> v0.6.0-rc4. * setup: mark the defaulted private-WiFi key provisional too setup_private_network writes /etc/tollgate/private-wifi-credential-provisional when it installs the fixed default passphrase (rather than one the operator chose), so the post-release admin/config UI can force a Wi-Fi passphrase change at first login alongside the admin one. The test asserts it is written only when the default is applied. --------- Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * docs(packaging): record the decommission plan for the module's own package build (#914) Freeze the intent, not yet the change: FreedomTechFeed/packages/net/tollgate-wrt/Makefile is the single canonical shipping package; this repo's packaging/Makefile is test/reproducibility only. packaging/README.md lists exactly what the feed consumes (packaging/files/) and the mechanical removal (Package/Build targets, build-ipk/local-build-ipk/build-env helpers, the build-package.yml publish jobs) to execute after the v0.6.0-rc4 release. Draft PR; merged post-release. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * test(harness): pin the env seams the setup script now resolves (release-check green) (#915) Three uci-defaults suites were red at release-check (they are NOT in the per-PR test.yml lane, so #913 merged with them red): - entry-ui-mapping (9): the suite's sed predated the TOLLGATE_ENTRY_UI_CONFIG env override, so the copied 99 read the HOST's live /etc/tollgate/config.json (the harness rot #899 describes). - admin-tls-identity (6) + trusted-entry-80 (1): the new private-WiFi provisional marker defaults to an absolute path the suites did not redirect. Harness-only: pin TOLLGATE_ENTRY_UI_CONFIG + ADMIN/PRIVATE_WIFI_PROVISIONAL_MARKER into each sandbox. NO change to 99-tollgate-setup (the risky half of #899). Sweep: all tests/packaging/* + tests/uci-defaults-* green. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * packaging: re-check the private subnet once the uplink is up (#926) setup_private_network derives network.private (lan/24 ±1) at uci-defaults time, before a fresh DHCP WAN necessarily has a lease, so an upstream that lands on the same /24 as the derived private network — the observed 192.168.2.1 against a 192.168.2.0/24 upstream — is missed. The router then answers for the upstream gateway's own address and DNS breaks on the deployed router. Add recheck_private_subnet (no-op unless the private /24 now overlaps a known upstream) and call it from the postinst AFTER `/etc/init.d/network restart`, reloading the network only when the private subnet had to move. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * fix(tollwallet): bound each mint registration so a slow mint cannot stall boot (#940) * fix(tollwallet): bound each mint registration so a slow mint cannot stall boot The wallet already registers only the mints the startup probe found reachable, but gonuts' AddMint does its own network I/O with NO http.Client timeout, so a mint that accepts the connection and never answers can stall the whole wallet construction — the module's own notes measure 113-347 s for 7 mints. That outlasts the installer's health window and made a healthy install report 'the merchant is degraded'. registerMint now gates AddMint behind a bounded GET /v1/keysets with OUR OWN client (8 s). A mint that does not answer is left UNREGISTERED rather than blocking boot: ensureMintRegistered retries it on first use and the reachable-set callbacks admit it when it recovers. Registration stays sequential, so gonuts is never called concurrently. Test: TestMintServesKeysets (serves keysets -> accepted; empty -> rejected; outruns the bound -> rejected). tollwallet + merchant modules green. * review: reword the bound's guarantee, state the residual, add a defer->recover test Addresses the independent review on the fork PR: - The comment no longer claims AddMint 'cannot hang'. The probe and AddMint are separate exchanges, so the bound makes a hang UNLIKELY rather than impossible; the real closure (a goroutine-with-timeout around AddMint, or registration off the boot path) is noted as a follow-up. - States the residual worst case explicitly: one registerMintKeysetTimeout per reachable mint, sequentially — 7 mints x 8 s = 56 s at boot — plus the new first-use latency for a deferred mint (a bounded <=8 s inline probe on the first payment routed to it; latency, not correctness). - Adds TestRegisterMintDefersThenRecovers: a mint DOWN at boot is left unregistered, and ensureMintRegistered (the path Receive takes) registers it once it recovers — pinning the integration claim through the real gonuts wallet, not just the probe unit. --------- Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * docs(operator-guide): fix the entry_ui=luci TLS-port attribution (#939) The SSL-command scope paragraph paired LuCI's :8443 with entry_ui=luci, inverted: under entry_ui=luci LuCI (uhttpd.main) owns the entry pair TLS port :443 and the board owns :8443. Both ports named in the old sentence belong to the entry_ui=board mapping. Ground truth: packaging/files/etc/uci-defaults/99-tollgate-setup port functions and docs/access-urls.md's mapping table. Found by the PR #744 review; one-line fix-forward. * docs: defer loopback rate-limit vs startup-gate follow-up (post-release) (#953) Record the root cause behind the 2026-10-16 'Setup failed' incident: the installer's own 3s poll exceeds RateLimitMiddleware's 10 req/min for 127.0.0.1, and the limiter runs before requireStarted, so the probe can get 429 {'error':'rate limit exceeded'} instead of the readable 503 {'code':'starting'} refusal. The installer side is worked around in rc33. The root-cause fix (exempt loopback from the limiter) is DEFERRED until after the current release — no new module RC in this line. Co-authored-by: c03rad0r <c03rad0r@users.noreply.github.com> * fix(upstream): select and blacklist upstreams by BSSID, not SSID (#910) * fix(upstream): select and blacklist upstreams by BSSID, not SSID One SSID can be served by several BSSIDs across different upstream networks — some routed to the internet, some captive or isolated. The manager keyed its blacklist by SSID and never pinned the scanned BSSID on the candidate, so a router that settled on a BSSID with no internet blacklisted the whole SSID, including any working sibling, and could never recover. The ICMP-only connectivity check could not tell "gateway but no internet" from "ok" either. - Blacklist by BSSID (falling back to the SSID only when it is unknown), skip candidates by BSSID, and blacklist the exact failed BSSID on an emergency switch. A sibling BSSID of the same SSID stays eligible. - Thread the candidate BSSID through SwitchUpstream and pin wireless.<iface>.bssid, clearing a stale pin when the candidate carries none. Manual CLI connect pins the strongest scanned BSSID for the SSID. - Replace the ICMP-only probe with a layered one — default route, DNS resolution, HTTP 204 captive-portal check — returning a reason (no-route/no-dns/captive/no-http). Used by the startup check, the main loop and post-switch verification. Tests: two-BSSID/one-SSID blacklist isolation, BSSID pin set/clear, and probe classification. make go-battery green across all 16 modules. * changelog: fragment for #910 (BSSID-aware upstream selection) --------- Co-authored-by: c03rad0r <c03rad0r@users.noreply.github.com> * ci(router-test): TBMG Trust Pipeline + bench lanes (dual twin) (#898) * ci(router-test): TBMG Trust Pipeline on the self-hosted runner + verdict comment Router-test is the TBMG Trust Pipeline entry point. Two changes: * Run the gateway dispatch on the self-hosted runner (role 86-tbmg-runner on hermes-nvme) over the private fips mesh with its dedicated per-machine key — no per-repo secrets and no metered LAN traffic. * Add a report job that posts ONE idempotent verdict comment per PR (marker <!-- tbmg-trust -->), so every PR shows whether it is ready to merge. Fork PRs (which must never run on a self-hosted runner) are reported as "not tested" instead of silently skipped. The security contract is preserved: PR -> isolated lab; bench only on main or an explicit dispatch behind bench-hardware. * docs(changelog): fragment for the TBMG Trust Pipeline (#898) * ci(router-test): dispatch the bench lanes on the gateway Add a gated `bench` job (non-PR, bench-hardware) that SSHes to the elected router-bench-gateway over fips and runs scripts/bench/bench-lanes.sh — the lane orchestrator (clean flash -> install -> smoke -> radios OFF, JSON summary). * ci(ngit): mirror the router-test trust pipeline into the ngit twin Land the trust pipeline in the ngit execution twin too (dual setup): same plan/router-test/bench jobs, ngit-safe (no GitHub `environment`/`vars`; the verdict is published by ngit-ci to Nostr, so the report job just summarises). --------- Co-authored-by: felixfelix-bot <felixfelix-bot@users.noreply.github.com> * feat(wireless): wire the vendor-IE price producer the scan boundary staged for (#959) * feat(wireless): wire the vendor-IE price producer the scan boundary staged for PricePerStep/StepSize were declared across four data models (NetworkInfo, Gateway, DiscoveryEntry, KnownTollGate) and had no producer: scanner.go hard-zeroed them with a 'STAGED (this PR)' comment, gated behind the VendorIEDiscovery flag whose consumer did not exist. Wire it: - scanner.go: capture the raw IE blob from the scan's 'IE: <hex>' line and route network assembly through a new buildNetworkInfo(), which calls the existing ParseVendorIEsFromScanData() to fill IsTollGate / RawIEs and the pricing fields. Remove the staged comment. - vendor_element_manager.go: add pricing TLVs (0x03 price_per_step, 0x04 step_size, both 2-byte big-endian) to the encoder and parser, so the IE can carry a price hint. - types.go: TollGateAdvertisement gains PricePerStep/StepSize. Authority is unchanged and now documented in-code: the IE is an UNSIGNED hint via OUI identification that selects a candidate for the signed kind-10021 probe on :2121, which remains the authority for price. A zero pricing value means 'not advertised here', never 'free'. Pricing TLVs are dropped (truncated=true) rather than overflowing the 255-byte element or pushing out the pubkey, because identification outranks the price hint. Tests (observed RED before the change, GREEN after): - TestParseIwinfoOutput_PopulatesVendorIEFields - TestParseIwinfoOutput_NonTollGateIEIgnored - TestParseIwinfoOutput_TollGateIEWithoutPricingTLVs - TestParseIwinfoOutput_NoIEMeansNotTollGate - TestParseIwinfoOutput_MultipleNetworksAndIEs - TestVendorIE_RoundTripPricing (+4 subtests) - TestVendorIE_PricingDroppedRatherThanOverflow Full wireless_gateway_manager suite green, including the five pre-existing vendor-IE tests (no regressions). * fix(wireless): parse the vendor element from the format the router actually emits Hardware verification on tollgate-A04E (2026-10-16) falsified the first implementation's assumption. Measured: - `iwinfo <dev> scan` prints NO information elements at all (grep -c 'IE:' returned 0), so it can never yield IsTollGate via the vendor element. It does report hidden APs, as `ESSID: "unknown"` — a literal placeholder. - `iw dev <dev> scan` is the ONLY element source, and it prints elements DECODED into named fields (SSID:, RSN:, HT capabilities:, ...), not as a hex blob. A vendor element appears as `Vendor specific: <colon-hex>`. - iw strips the element ID (0xDD) and the length byte from that body. Feeding the body straight to ParseVendorIEsFromScanData returns 0 elements: measured as 36 good bytes in, 0 advertisements out. The body must be re-framed with its 0xDD + length prefix. Change: - ParseIwinfoOutput now accepts BOTH real formats: iwinfo `Cell NN - Address:` records and iw `BSS <mac>(on ...)` records, per-record, so a stream carrying either parses. - SSID parsing handles both keys; RSN supplies encryption for iw output (it has no `Encryption:` line); `freq:` supplies the channel. - The vendor element is re-framed before parsing. - Placeholder SSIDs ("", "(hidden)", "unknown") are refused admission, so a literal cannot become a selectable upstream. Tests rewritten against the measured format: TestParseIWScanOutput_{PopulatesVendorIEFields,NonTollGateVendorIgnored, TollGateWithoutPricingTLVs,NoVendorElementMeansNotTollGate, RealHardwareHiddenSSID,MultipleNetworksAndVendorElements}. Three failed against the raw-body bug and pass with the re-frame; the full wireless_gateway_manager suite is green with no regressions. * fix(wireless): read VendorIEDiscovery from UpstreamWifi; scan via iw when enabled Build fix: the flag lives on the UpstreamWifi sub-struct, not on Config. With that wired, the scan path can finally populate the fields the vendor element carries. Two facts measured on tollgate-A04E (2026-10-16) drive the design: 1. `iwinfo <dev> scan` prints NO information elements, so it can never identify a TollGate by vendor element. It is kept because it reports hidden APs (as ESSID "unknown") that iw can omit. 2. `iw dev <dev> scan` is the only element source. When vendor-IE discovery is enabled, scanRadio runs it as well and mergeScanResults folds the two views by BSSID, letting the element-derived fields (IsTollGate, RawIEs, price, step) come from iw while iwinfo still contributes entries iw lacks. iw is best-effort: a failure or empty result keeps the iwinfo view rather than failing the scan. * test(wireless): cover mergeScanResults, scanRadioWithIW and the flag wiring Coverage on the newly added code was incomplete: mergeScanResults and scanRadioWithIW were both at 0%, and parseColonHex at 75%. The merge is the join that lets element-derived fields come from iw while iwinfo-only entries (notably hidden APs) survive, so an untested merge was the largest risk in this change. Added: - mergeScanResults: enriches an existing BSSID from the second view; keeps entries only one view saw; fills gaps (signal/encryption); is the identity when the second view is empty. - scanRadioWithIW: a failed iw invocation returns nil rather than propagating an error, so the caller keeps the iwinfo view. - NewScanner/SetVendorIEDiscovery wiring. Also moves the on-router demo to src/cmd/tg-vendor-ie-demo, so it compiles against the repo's own module graph (the nested scanner module is reached via the existing replace directives) instead of a hand-rolled go.mod. Coverage on the new surface is now: mergeScanResults 100%, appendScanEntry 100%, NewScanner 100%, SetVendorIEDiscovery 100%, ParseIwinfoOutput 98.6%, buildNetworkInfo 92.9%, scanRadioWithIW 70%, parseColonHex 75%. * docs(wireless): record the cross-build target defect found on hardware The demo binary was built with GOOS=linux GOARCH=arm64 but the router rejected it with 'syntax error: unexpected end of file'. Its ELF header read 02 00 3e 00 (e_machine 0x3e = x86-64) instead of 02 00 b7 00 (0xb7 = aarch64). The sha256 matched the local file exactly, so the transfer was correct — the build target was wrong. Adding CGO_ENABLED=0 produced a proper statically linked aarch64 binary. The check that would have caught it in one line, now recorded for reuse: head -c 20 <binary> | od -An -tx1 | tail -1 # want '02 00 b7 00' for aarch64 Size and sha256 are NOT evidence of the right architecture; both matched while the binary was unrunnable on the target. * feat(demo): add --file mode and record the on-router demo evidence The demo could only run 'iw dev <dev> scan' live. Where no neighbour running this build exists (as on tollgate-A04E), that can only ever show the ABSENCE of a vendor element, never the positive identification path. Adding '--file <path>' lets the parser be run against a saved scan, so a synthetic element can be injected into REAL captured router output. Evidence from the live MT6000 (GL.iNet GL-MT6000, OpenWrt 24.10.2, aarch64_cortex-a53), 2026-10-16: A. live scan: iw dev phy0-ap0 scan: 28736 bytes, err=<nil> Vendor specific: lines present = 0 parsed networks = 12 identified TollGates = 0 (correct: no neighbour runs this build) B. real scan + one injected TollGate element: read /tmp/injected-scan.txt: 35168 bytes Vendor specific: lines present = 1 parsed networks = 16 TOLLGATE bssid=b2:36:4a:2a:03:32 ssid="StarGate" signal=0 price_per_step=7 step_size=1000 rawIE_bytes=38 identified TollGates = 1 -> the parser located the element inside genuine scan data and recovered the encoded price/step exactly. The demo binary was cross-built with CGO_ENABLED=0 and its ELF machine field verified as 0xb7 (aarch64) BEFORE transfer — the check whose absence produced the unrunnable x86-64 binary recorded in the previous commit. * fix(wireless): emit pricing before the pubkey so an oversized pubkey fails loudly Defect found by a cold cross-family review (deepseek-flash, PR #959, 2026-10-16). With the original ordering a pubkey could pass the fit check and land the body at 253-255 bytes; the pricing TLVs were then appended AFTER it, the 4-byte guard fired, and the pricing was silently dropped while the element still reported success. A router whose pubkey drifted large would appear to advertise no price — indistinguishable from a router that advertises none. Fix: emit pricing BEFORE the pubkey. An oversized pubkey now fails the existing loud error path ('vendor IE body too long') instead of silently losing the price. The length octet is also clamped explicitly with truncated=true, so a wrap can never produce a malformed element silently. Tests (observed RED against the original ordering, GREEN after): - TestEncode_PricingNotDroppedByPubkey (fails: 'pubkey 239 bytes cannot fit') - TestEncode_PricingSurvivesWithMaxFitPubkey (loud error for oversized pubkey) - TestEncode_LengthOctetNeverWraps (octet == body length) Encoder coverage 87.2%. Full wireless_gateway_manager suite green; go vet clean. MOUNTED BINARY VERIFIED: demo rebuilt for the router and its ELF machine field checked as 02 00 b7 00 (aarch64) before any transfer. Credit: this defect was surfaced by the cross-family lane, not by me. * refactor(wireless): rename the scan-side price hint so it cannot be confused with the authoritative one Follow-up from a cold cross-family review of PR #959 (deepseek lane, nit Q5). NetworkInfo.PricePerStep shared a name with PricingOption.PricePerStep, which IS authoritative (built only from a signature-verified kind-10021 event, and the only field the money path spends). The shared name is what caused the v1 review to raise a false blocker: from the excerpt it could not tell which field was authoritative. Renamed on the scan/display side only: NetworkInfo.PricePerStep -> NetworkInfo.AdvertisedPricePerStep NetworkInfo.StepSize -> NetworkInfo.AdvertisedStepSize DiscoveryEntry / KnownTollGate / cli.UpstreamNetwork likewise. NOT renamed (deliberately, the authoritative/money-side fields): config_manager.MintConfig.PricePerStep tollgate_protocol.PricingOption.PricePerStep TollGateAdvertisement.PricePerStep (the IE encoder's own field) JSON wire keys are UNCHANGED (price_per_step, step_size) - a documented operator-facing contract (docs/operator-guide.md) - so this is a Go identifier change only and is wire-compatible. The field comment now states the authority boundary explicitly, so the next reader does not have to reconstruct validator.go -> session.go to learn that a zero here means "not advertised", never "free". Verified: whole tree builds, go vet clean, wireless_gateway_manager and cli test suites green, no NetworkInfo-side references to the old names remain, and the money path is untouched. * chore: keep session scratch out of the PR PR-BODY.md, PROGRESS.md and build-review-prompt*.py are this session's working artifacts (the PR description and the review prompts), not deliverables. They were swept in by 'git add -A'. Untracked and gitignored; the files stay on disk. Leaving them in would put review-harness scratch in an upstream PR diff. --------- Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * feat(wireless): make the vendor-IE emitter reachable (#960) * feat(wireless): make the vendor-IE emitter reachable, preserving foreign elements STACKED on #959 (pr/vendor-ie-price-producer). The encoder's pricing TLVs exist only there, so this cannot stand alone; do not merge before #959. #959 wired the READ path (scan -> IsTollGate + pricing). Nobody had wired the WRITE path, so no router could advertise itself and the read path had nothing to find: EmitTollGateVendorIE had ZERO callers (dead code) GetLocalAPVendorElements was a stub returning an empty map Hostapd set_vendor_elements REPLACES the whole element set, and the old implementation sent only its own hex Three defects fixed: 1. DEAD CODE. The emitter had no caller and the reader was a stub, so the round-trip (what did I advertise?) could not be verified at all. The reader now reports the last successfully applied set. Documented limitation: this is the processor's own record, NOT a hostapd read-back, because OpenWrt's hostapd exposes set_vendor_elements with no getter. 2. CLOBBERING. set_vendor_elements replaces the entire set, so writing only our own element would WIPE co-resident vendor elements (11k/11v, mesh, other tooling) on every update. Foreign elements observed on the AP are preserved and re-sent alongside ours. Payload ordering is sorted so the same element set always serialises identically. 3. SILENT FAILURE. The old code returned nil even when every hostapd call failed, so a router could fail to advertise and report success - indistinguishable from a router with nothing to say. All-calls-failed is now an error; no-hostapd (radio down) remains a benign no-op. Gating: NewVendorElementProcessor defaults to DISABLED and EmitTollGateVendorIE is a no-op when disabled, so merging this changes nothing until an operator sets vendor_ie_discovery=true. Enforced by tests in both directions. Test seam: added Connector.runUbus, the same injection pattern already used for runUCI (#817), so the emitter's tests exercise the real code path. Tests (observed RED first - the seam and fields did not exist): TestGetLocalAPVendorElements_RoundTripsWhatWasSet TestSetLocalAPVendorElements_PreservesForeignElements TestSetLocalAPVendorElements_FailsLoudWhenAllCallsFail TestSetLocalAPVendorElements_NoHostapdIsNotAnError TestEmitTollGateVendorIE_SendsEncodedElement TestEmitTollGateVendorIE_GatedOffDoesNothing TestNewVendorElementProcessor_DefaultsToDisabled / _EnabledWhenAsked TestVendorElementProcessor_DisabledWritesNothing / _EnabledWrites TestSetLocalAPVendorElements_PayloadIsDeterministic Coverage: SetLocalAPVendorElements 93.9%, GetLocalAPVendorElements 100%, NewVendorElementProcessor 100%, EmitTollGateVendorIE 77.8%. NOT in this PR, stated so the gap is not mistaken for done: the daemon does not yet CALL the emitter on price change or at startup. Doing so needs a re-apply-on-restart story (hostapd loses runtime-only vendor elements), which is its own change. Until that lands, this PR makes the emitter correct and reachable, not automatic. * fix(wireless): make foreign-element preservation real, not theatre Fixes from a cold cross-family review of this PR (deepseek lane, 1 critical + 2 major). All three were cases where the code looked right and the tests passed because the tests supplied the very input production never provided. CRITICAL - preservation was theatre. foreignElements was populated ONLY by tests; nothing in production ever filled it, so the merge loop iterated an empty map and the first write replaced whatever was on the AP. The comment claimed a property the code did not have - the same defect class as the dead-code emitter this PR fixes. Now loadConfiguredVendorElements() populates it from the uci wireless config (where a persisted vendor_elements option lives), skipping our own OUI so it is not duplicated. Verified by mutation: removing the load call makes the new test fail with the clobbering payload '{"vendor_elements":"dd06212121"}' and nothing else. HONEST LIMITATION, now in the field comment: hostapd exposes set_vendor_elements with NO getter, so runtime-only elements CANNOT be read back and nothing in this package can discover them. A caller that knows them must supply them. This is stated rather than papered over. MAJOR - ubus failure looked like "no hostapd". A failed `ubus list` returned nil, making a broken ubus indistinguishable from a healthy router whose radio is off, so a failure to advertise was invisible. It is now an error. MAJOR - partial failure was reported as success. Advertising on 1 of 2 APs returned nil, so a router advertising on ZERO APs could still look fine. A partial result is now surfaced; full failure still errors; no-hostapd remains a benign no-op. MAJOR - the getter under-reported. It recorded only OUR elements, omitting the preserved foreign ones that are equally on the air. It now records the merged set actually sent. REFUTED (not acted on): the review's Q2 claimed the disabled gate is only checked in the emitter so a direct SetLocalAPVendorElements call bypasses it. The guard IS the first statement of SetLocalAPVendorElements. Verified by reading and by the existing TestVendorElementProcessor_DisabledWritesNothing, which asserts zero ubus calls. Recorded here because a refutation is evidence too. Tests (each fails without its fix): TestSetLocalAPVendorElements_PreservesElementsFromUCI (production path only) TestLoadConfiguredVendorElements_DoesNotCaptureOurOwn TestSetLocalAPVendorElements_UbusListFailureIsAnError TestSetLocalAPVendorElements_PartialFailureIsReported TestGetLocalAPVendorElements_ReportsMergedSet TestLoadConfiguredVendorElements_UCIFailureIsTolerated Full wireless_gateway_manager suite green. Credit: all three real defects were found by the cross-family lane; I shipped them. --------- Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * feat(mptcp): multi-WAN bonding integration seam (#927) * feat(mptcp): multi-WAN bonding integration seam for TollGate Adds src/mptcp_bonding, the validated core of an MPTCP multi-WAN bond as an *uplink* concern. Portal, Cashu payment, session accounting and merchant mode are untouched: bonding replaces only how packets leave the box. The package is deliberately pure (no syscalls, no file I/O) so the steering decision lives in one testable place, shared by both the generated firewall rule and any Go caller. policy defaults to guest_only so the box never diverts its own management traffic into the transparent proxy. A naive divert during bring-up cost an operator shell access to a field device. Validation is strict: a half-configured bond silently changes how a paying customer's traffic leaves the device. It therefore rejects fewer than two uplinks, a malformed server or port, guest_only without a parseable CIDR, colliding socks/tproxy ports, duplicate uplinks, and an unparseable fwmark. enabled=false validates with nothing else set. Tests: 10 tests / 32 cases, 92.1 percent coverage, go vet clean, gofmt clean. Written test-first: with the implementation removed the suite fails to build (undefined Config and DefaultSubflows). docs/MPTCP-BONDING.md records what is proven on hardware (MP_JOIN captured on the wire, subflows:2 live, MPTCP verified end-to-end) and what is not: mid-transfer failover is untested, and aggregation is roughly 1.2 to 1.3 times, not 2 times. * feat(mptcp): render MPTCP endpoint + limits provisioning Adds the node-side kernel configuration the bond needs, as pure renderers so the plan is unit-testable without a router: EndpointPlan() one `ip mptcp endpoint add <addr> dev <iface> signal subflow` per uplink with a known address LimitsSetCommand() `ip mptcp limits set subflow N add_addr_accepted N`, raising the subflow ceiling to the uplink count but never lowering it below PrerequisitePackages() names ip-full Both renderers return nothing when the bond is disabled, so a device that has not opted in emits no commands. These pin three failures the lab found, all of which are silent on device: * a `subflow`-only endpoint never sends ADD_ADDR, so the peer never learns the address and no second subflow can form. It looks configured and does nothing. Every endpoint must be `signal subflow`. * the kernel default of 2 subflows is below what a multi-WAN box needs; the extra uplinks would be configured but never used. * BusyBox `ip` has no `mptcp` subcommand, so ip-full is required or every command fails with a usage error on a stock image. Docs record the FIPS-side change this depends on: enabling MPTCP on the node cannot make the socket multipath-capable, because FIPS creates its own socket. The transport patch is carried on the fork at felixfelix-bot/fips @ pr/tcp-mptcp-option (documented, not PR'd upstream pending the aggregation measurement). Tests: 8 new, all RED first. Package coverage 90.2%. go vet and gofmt clean. Refs: docs/MPTCP-BONDING.md * docs(mptcp): lab measurement - subflow formation proven, aggregation topology-limited Records the first successful two-disjoint-path MPTCP measurement on the GL-MT6000 lab router (tollgate-A04E, OpenWrt 24.10.2). Subflow formation is PROVEN. With both ends configured `signal subflow`, a real second subflow forms and completes its handshake: MPJoinSynRx 0 -> 2 (JOINs received) MPJoinAckRx 0 -> 2 (handshakes completed) ss -Min: subflows:1 subflows_total:2 local_addr_used:3 That counter stayed at 0 in every earlier run; the `signal` flag is what unblocked it. Aggregation is NOT achieved, and the reason is topological rather than a defect in this code. Measured on two disjoint L2 segments that both terminate at the same router: single path (--bind-dev) 925-928 Mbit/s unbound (MPTCP free to use both) 919-921 Mbit/s No gain. Conntrack shows only one of the two MPTCP connections carrying data: both subflows converge at one router with one upstream, so the scheduler keeps traffic on the better subflow rather than splitting across a shared bottleneck. Stated plainly so the docs do not overclaim: subflow formation yes, throughput aggregation no, and independent end-to-end paths are required to demonstrate the latter. Refs: docs/MPTCP-MEASUREMENT.md * chore(mptcp): track PROGRESS.md for cross-iteration resumability Records iteration 1 findings so a capped-out or re-dispatched worker can resume without re-running the lab work: * the lab DOES have two disjoint L2 paths (supersedes the earlier single-link claim); * client endpoints were subflow-only, missing signal -- real blocker; * subflow formation is PROVEN (MPJoinSynRx 0 -> 2); * aggregation is topology-limited (two L2s, one upstream) and the conntrack evidence shows only one connection carrying data. Refs: docs/MPTCP-MEASUREMENT.md * feat(mptcp): idempotent applier that renders the bond onto the device Adds Config.ApplyScript(), which renders the whole bond as one ordered, re-runnable shell script. It is a renderer, not an executor: no I/O, no syscalls, so the exact bytes can be asserted in a unit test and the same script handed to whatever applies it (systemd, the module's applier, an Ansible task, or an operator reading it). Ordering is load-bearing and now enforced by test: 1. Preconditions first -- `ip` present, net.mptcp.enabled=1, and an `ip` that actually has the mptcp subcommand. Without this the failure mode is a confusing "unknown command" from BusyBox ip halfway through, with the device left partially configured. 2. `ip mptcp limits set` BEFORE any endpoint exists. Raising the ceiling after registration would leave the first endpoints rejected. 3. Endpoints, each preceded by `ip mptcp endpoint delete <addr>` so a re-run cannot fail on an already-registered address. Idempotence is the point. A reconcile runs on every boot and every interface change; a script that only works the first time leaves a field device unconfigured after one restart. The delete-then-add guard is what makes a repeat run a no-op instead of an error. The bind password is deliberately never rendered: it belongs in the transport's own configuration, and writing it here would put a credential in a file at a predictable path. Verification: * 4 new tests, written RED first (build failed with `ApplyScript undefined`). * package coverage 90.3%, go vet clean, gofmt clean. * rendered script passes `sh -n` (valid shell). * EXECUTED TWICE on the real router (GL-MT6000 tollgate-A04E) and the post-condition read back from the device: RUN1_OK, RUN2_OK, endpoint set byte-identical before and after both runs, limits correct at `add_addr_accepted 4 subflows 4`. No duplicates, no failures. * chore(mptcp): PROGRESS iteration 2 - applier done, proven idempotent on device ApplyScript committed as d927deb; verified by running it twice on the GL-MT6000 with the post-condition read back from the device (identical endpoint set, RUN1_OK/RUN2_OK, limits correct). Flags the one remaining operator decision: aggregation needs a second end-to-end path, which this lab does not have. * docs(mptcp): operator runbook for enabling a bond on OpenWrt Adds docs/MPTCP-OPERATOR-RUNBOOK.md plus scripts/read_mptcp_counter.sh, so an operator can enable and, more importantly, VERIFY a bond without rediscovering the lab's failure modes. The runbook leads with the honest expectation (resilient multi-path, 1.2-1.3x, not 2x) and then documents the three silent failures, each of which leaves a bond looking configured while carrying everything on one path: 1. An endpoint must be `signal subflow`. Without `signal` the kernel never sends ADD_ADDR, the peer never learns the address, and MPTcpExtMPJoinSynRx stays 0 forever while the config looks right. 2. The kernel default of 2 subflows is below a multi-WAN box's needs; `ip mptcp limits set subflow N add_addr_accepted N` must cover the uplink count. 3. Two addresses on one L2 is not two paths. The runbook includes a three-row table contrasting one-L2 / two-disjoint-L2s-one-upstream / two-disjoint-L2s-two-upstreams, with what each one actually yields -- because "the subflow formed but throughput did not change" is the confusing case and it is a topology property, not a misconfiguration. Every command in the runbook was executed against the real router before being written down, and that caught three defects in my own draft: * `ss -Min` prints NOTHING for MPTCP sockets on this OpenWrt build, so the runbook no longer tells operators to read subflows from it; * `paste` is not in BusyBox, so the obvious awk|tr|paste one-liner fails on OpenWrt -- replaced with scripts/read_mptcp_counter.sh; * `ip route get` against the router's OWN address returns `local` and proves nothing, so the disjoint-path check is now specified as client-side and against the peer's addresses. read_mptcp_counter.sh walks the positional MPTcpExt header/values lines with POSIX shell only. Verified ON THE DEVICE in BusyBox ash: SYNTAX_OK, `MPJoinSynRx` -> 2, `MPJoinAckRx` -> 2, `MPCurrEstab` -> 0, no-arg mode prints all pairs, and an unknown counter exits 1 with a clear message. Also documents the wpad/ujail control-socket fix (chown network:network /var/run/wpa_supplicant, chmod 770) and warns that it is not persistent across reboot since /var/run is tmpfs. * chore(mptcp): PROGRESS iteration 3 - operator runbook done Runbook + read_mptcp_counter.sh committed as 5c30576. Records that verifying the runbook against the real router caught three defects in the draft (ss -Min empty on OpenWrt, paste missing from BusyBox, ip route get returning local for the router's own address). * feat(mptcp): reconciler keeps bond state in step with interface events Adds the seam between the network monitor and the kernel: Reconciler turns "an uplink came up / went away / changed address" into "re-render and apply the bond". Why this exists: a bond on a mobile box is a moving target. A wireless uplink appears when the STA associates, disappears when it roams, and its address changes when DHCP renews. A script applied once at boot drifts from reality within minutes, and the failure is SILENT -- the bond quietly stops using the interface that came back and nobody notices until throughput is wrong. Two rules keep it safe: * Only uplinks declared in Config matter. A router has many interfaces that flap (guest AP, loopback, container bridges); reconciling on all of them would thrash kernel state for no benefit. * Apply only when the rendered script actually changed. Reconcile runs on every event, and the apply script is delete-then-add, so re-applying an identical script would tear down and rebuild every endpoint -- breaking an in-flight subflow on every unrelated event. An unchanged render is skipped. The side effect is injected as an `Applier func(script string) error` so the decision logic is unit-testable with no router attached, and so the caller chooses how the script is executed. A nil Applier (or nil Config) makes the reconciler inert rather than panicking, since the monitor may fire before configuration is loaded. Methods mirror the upstream_detector event set (HandleInterfaceUp / HandleAddressAdded / HandleInterfaceDown / HandleAddressDeleted) so wiring it to that module's event loop later is a direct call-through with no adapter. Not done here, deliberately: mptcp_bonding is still not imported by main.go or upstream_detector, so nothing calls these methods yet. This is the missing seam, not the wiring. Recorded in PROGRESS.md. Tests: 6 reconciler tests + 8 event/introspection tests, all written RED first (build failed with `undefined: NewReconciler`). Package coverage 91.0% (up from 90.3%), 35 tests, go vet clean, gofmt clean, go build clean. * chore(mptcp): PROGRESS iteration 4 - reconciler built; wiring is not yet possible Records that item 5 was premature: mptcp_bonding is not imported anywhere and not initialised in main.go, so there was no lifecycle to attach an event handler to. Built the missing seam (Reconciler, c8e2d0f) with methods mirroring upstream_detector's event set, and flagged the remaining cross-module wiring as the new next item with its blast radius noted. * feat(mptcp): config loader keeps the package a leaf module Adds LoadConfig([]byte) (*Config, error), which the host module calls with raw JSON from config_manager. Why a loader and not a config_manager import: this package must stay a LEAF. upstream_detector already depends on config_manager, so if mptcp_bonding also depended on config_manager and upstream_detector then imported mptcp_bonding, the edges would close into a cycle. Keeping mptcp_bonding dependency-free means the cross-module integration stays one-directional and this decision can be tested with no running config manager. Three behaviours worth stating: * Absent config is a DISABLED bond, not an error. Every node deployed before this feature has no such section; failing to load would take the module down on upgrade instead of leaving the node exactly as it was. * Unknown keys are REJECTED (DisallowUnknownFields). A typo like `enabld: true` silently leaving the bond off is the class of bug that costs hours in the field, so it is loud. * Validation runs at load, so an unusable bond fails at startup with a clear message rather than silently misrouting a paying customer's traffic. Also adds ConfigJSONExample(), which renders the on-disk shape FROM the type so the documented example cannot drift from the real field names. It deliberately emits no password field: the credential is supplied at runtime and must never be committed or printed. Not done here: main.go still does not load or apply this. This is the package-side of the seam; the host-side wiring (main.go + config_manager schema + upstream_detector event call-through) is the remaining step and is recorded in PROGRESS.md with its blast radius. Tests: 5 loader tests written RED first (build failed with `undefined: LoadConfig`), plus an example-config test. 41 tests total, coverage 91.1%, go vet clean, gofmt clean. * chore(mptcp): PROGRESS iteration 5 - loader done; host wiring split out Records the cycle hazard that forces mptcp_bonding to stay a leaf module (upstream_detector already depends on config_manager), the LoadConfig behaviours (2071e0c), and that the remaining host-side wiring 6a/6b/6c touches the module managing live node connectivity -- flagged rather than executed. * docs(mptcp): two-gateway measurement - StarGate uplink works, test was mis-shaped Operator chose option (a): put StarGate on the router as a second uplink and retry for a real aggregate number. ACHIEVED: the router now has two uplinks with genuinely independent gateways, which is what the previous attempt lacked. lan1 192.168.2.15/24 -> 192.168.2.1 (house wired) phy0-sta0 10.2.1.122/16 -> 10.2.0.1 (StarGate / Starlink) STA built via uci + wifi reload (per the MT7986 rule), associated at -73 dBm, gateway pings at 2.1 ms, and StarGate reaches 8.8.8.8 at 35 ms. MPTCP endpoint registered `signal subflow`. Subflow formation still healthy (MPJoinSynRx delta 2, MPCurrEstab 2). RESULT: throughput unchanged, 925-927 single-path vs 926-928 bonded. But the important finding is that THIS TEST WAS MIS-SHAPED, and that is stated in the document rather than glossed: * the iperf3 server runs ON THE ROUTER, so traffic stops at the router's own socket and NEVER TRAVERSES THE UPLINKS. No uplink topology could show up in that number. * the client link is 1 GbE and measured 925-928 Mbit/s = wire speed, so the local hop is saturated anyway. So the honest conclusion is "aggregation was not tested by this run", not "aggregation failed". The mechanism is proven; the throughput question stays open pending a test with the far end beyond the uplinks and no saturated hop. Refs: docs/MPTCP-MEASUREMENT-2GATEWAY.md * docs(mptcp): per-subflow evidence - 2nd subflow forms on the SAME path Decisive conntrack evidence for the no-gain result: during a live 30s transfer both MPTCP connections carry the SAME 5-tuple addresses (10.63.101.201 -> 10.63.101.1), differing only in source port. sport=51104 41747 pkts 869303545 bytes <- all data sport=51090 8 pkts 725 bytes <- handshake only So the second subflow is created on the same path as the first: healthy handshake, zero capacity added. This is a distinct mechanism from both earlier explanations and explains the flat throughput precisely. Also records that only the 'default' MPTCP scheduler exists on this kernel (roundrobin/redundant refused), so the latency-preferring default cannot be swapped -- and that the default is correct to prefer one path anyway. * docs(mptcp): dual-path measurement - two subflows carry data, no gain from a shared segment Follows the two-gateway attempt. The client was pointed at the router over BOTH of its local paths so the subflows could not collapse onto one 5-tuple: wlp58s0 192.168.2.36 -> router 192.168.2.15 (wifi -> house LAN) enx6c6e0750c33b 10.63.101.201 -> router 192.168.2.15 (direct cable) Obstacle cleared first: `lan1` is classified as WAN on this router (`iifname "lan1" jump input_wan`, policy drop), so inbound TCP to 192.168.2.15 was refused while ICMP worked. A temporary accept rule for the test port was inserted and then removed (verified: handle deleted, ruleset count 0). PROVEN: two subflows on two different client interfaces carried real data at the same time. Mid-transfer conntrack shows both ESTABLISHED with large counts (49823 pkts / 510695705 B and 85187 pkts / 1256176480 B from two distinct client source addresses), and MPJoinSynRx advanced 2 -> 16. NOT PROVEN, and stated as such: a throughput gain. wifi only 1.25 Gbit/s cable only 1.20 Gbit/s aggregated (3 runs) 1.19 / 1.21 / 1.27 Gbit/s Aggregated sits within noise of either single path. Cause: both of the client's paths terminate on the SAME house LAN switch. The subflows are logically distinct but physically share one network, so there is no capacity to aggregate and the scheduler correctly declines to split across a shared bottleneck. The lesson generalises: subflows must be disjoint all the way down, not merely distinct at the socket level -- distinct local addresses are necessary but not sufficient. Refs: docs/MPTCP-MEASUREMENT-DUALPATH.md * chore(mptcp): PROGRESS iteration 6 - StarGate uplink live, two real subflows, no gain Records: StarGate STA built via uci+wifi reload (10.2.1.122/16, gw 10.2.0.1); two subflows on two different client interfaces carried real data simultaneously (distinct source addrs, large byte counts); but no throughput gain because both client paths share the same house LAN switch. Also records the invalid earlier test (server on the router, traffic never reached the uplinks) and that the router was left clean. * feat(config): mptcp_bonding config section, disabled by default 6a + 6b of the host-side wiring. Adds the schema entry and the typed struct so an operator can set a bond through the normal `tollgate config` path. Both of this package's guardrails fired during the work and each caught a real defect, which is why they exist: * TestSchemaHasNoOrphanEntries rejected the schema entry until the Config struct gained a matching json tag -- so the two cannot drift. * TestSchemaDefaultsMatchNewDefaultConfig rejected the schema defaults until NewDefaultConfig carried them -- so schema and code defaults stay in step. Design choices worth stating: * Requir…
…reStartupDegraded refactor plus the branch's StorageIncompatible banner, which main does not carry) (#1092) * docs: environment truth and drift doctrine — one declaration, glob discovery, fenced duplication, the two-era policy (#796) (#799) The gonuts pin (#791), the port table (#746), and the #796 audit all have one shape: a fact written in more than one place. AGENTS.md now states the rules that prevent the class instead of enumerating the values that drifted: one declaration per fact, discovery by glob (never a count in prose), manifest truth for externals, behavioral fences with planted-drift verdicts, generators that write every copy. Plus the bridge vocabulary and its single Go declaration site, the nft-fragment port-fencing rule, the apk/ipk era probe (never release-string parsing), the 25.12-primary / 24.10.8-frozen support policy, and the cross-repo-halves contract. Co-authored-by: pr-train-lane <lane@opencode> * fix(packaging): ship the GPL license text in BOTH release lanes (#751) (#787) The SDK-free .ipk lanes installed the GPL-3.0 text at /usr/share/doc/tollgate-wrt/LICENSE while the SDK .apk lane (packaging/Makefile) was metadata-only — PKG_LICENSE_FILES feeds the feed's license manifest, not the payload — so the same version shipped different contents per format, and this ~35 KB file was the lone content delta blocking byte-equivalence between the lanes. Decision, on compliance grounds: both formats ship the text. GPL-3.0 distribution reaches the recipient together with the binary — the conservative reading the issue itself names — and the metadata (PKG_LICENSE) and the shipped text are complementary, never either/or. The cost is ~35 KB on any router. - packaging/Makefile installs $(PKG_BUILD_DIR)/LICENSE to /usr/share/doc/$(PKG_NAME)/LICENSE in Package/install — the same payload path the .ipk lanes use. - Build/Prepare now REQUIRES the LICENSE (the copy was best-effort `|| true`): a GPL build that cannot find its license fails loudly instead of silently shipping a binary without it. - assert-artifact-contents.sh (run on every lane's real artifact in CI) hard-fails any package — .ipk or .apk — whose payload lacks the file, and reports its byte size. - tests/packaging/license-both-lanes_test.sh pins every staging surface (Makefile install + hard requirement, local-build-ipk, the CI ipk lane), path agreement across lanes, the metadata staying intact, and the artifact gate staying hard; wired into test.yml. - The two artifact-building fixture suites stage the real LICENSE so their synthetic .ipks match a real one (the new gate caught them immediately — the invariant works). Verified with a synthetic artifact both ways: with the license the harness PASSes (35593 bytes), without it the hard FAIL fires (exit 1). Packaging suite green except apk-install-resolution (docker-gated, fails identically on pristine main). Fixes #751 * feat(packaging): let the operator choose the admin password at install time (#827) A router whose root password was unset received a GENERATED credential, printed exactly once. Its only other copy went to /tmp/tollgate-setup.log -- tmpfs, and truncated at the start of every full-setup run -- so an install or upgrade erased the record. "Shown once" is only safe if there is a way not to need it. Two changes: - The install honours TOLLGATE_ADMIN_PASSWORD. uci-defaults run from the package manager with no controlling terminal, so the environment is where a choice can arrive; this is also the mechanism a fleet or CI provisioning script uses. The value is applied to root, never echoed to the output or the setup log, and replaces an existing credential when supplied. If it does not take, the gate fails closed and drops the admin listeners rather than leaving the board on the old credential. - A credential this package GENERATES is now marked provisional by /etc/tollgate/admin-credential-provisional (no secret in it -- the file is world-readable). The marker is (a) the signal the config UI reads to force a password change at first login and (b) the reason a later setup run re-states the recovery path instead of implying the operator chose it. A locked root account and an unreadable shadow stay exempt: neither is a state this package should decide to unlock. Tests extend tests/packaging/admin-board-requires-credential_test.sh (its own harness, per repo doctrine). RED observed first: 5 failures against the pre-change script with the 77 existing assertions passing. Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * fix(packaging): declare the CA bundle and state the manual-install trust chain (#828) * fix(packaging): declare the CA bundle and state the manual-install trust chain Installing the shipped package by hand on a GL-MT3000 (OpenWrt 25.12.5) failed at the first HTTPS fetch: wget AND apk update both died with "SSL verify error: unknown error". The image had no CA store, so no TLS connection could be verified. This module's job is money. It talks to Cashu mints and Lightning over TLS, the Go binary uses the system root pool (no InsecureSkipVerify, no bundled pool), so without a CA bundle every mint and Lightning call fails verification -- a gateway that cannot verify TLS cannot take a payment. The installer used to paper over this by fixing the box first; installing the package alone has to work, so ca-bundle is now a declared dependency rather than an assumption. The docs also gain the chain that makes an unsigned artifact safe to install by hand, because "install it yourself" is only honest if the verification is stated: the release's SHA256SUMS is signed (SSHSIG/ed25519, public half at .github/release-keys/release-signing.pub), so verify the signature FIRST, then check the artifact hash, then install. The artifact carries no apk-level signature, which is exactly why the manifest must be verified before the bytes are trusted. On a stock router ssh-keygen is absent (dropbear ships, not OpenSSH), so the manifest is verified on a trusted machine and the hash checked on the router. tests/packaging/apk-install-resolution_test.sh gains source-level assertions for both declarations, run BEFORE its docker gate so a machine without docker still checks them, and its header's honesty list now says what it does and does not prove about trust. RED observed first (both source files reverted, test kept): the CA assertion and two of the three documented-chain assertions fail. GREEN after: all four pass and the docker closure lane still resolves (12.5 MiB, 156 packages). * docs(packaging): point manifest verification at allowed_signers, not the bare key The README named .github/release-keys/release-signing.pub as the key to use, while the command it printed passed -f allowed_signers. Those are different files in different formats: release-signing.pub is a bare ed25519 public key, allowed_signers is the same key prefixed with the principal the -I identity is matched against. Following the prose sent the reader to a file the verifier rejects. Name the file the verifier consumes and fetch it explicitly. The lane-pinning needle added here is a PRESENCE pin only: allowed_signers already appeared in the printed command, so it does not go RED against the pre-fix README. This fixes documentation accuracy, not behaviour. * fix(packaging): declare the CA bundle on the .ipk lanes too The CA-bundle dependency was declared only in the SDK recipe (packaging/Makefile). The two .ipk build lanes -- packaging/local-build-ipk.sh and .github/workflows/build-package.yml -- still stamped `Depends: libc, nodogsplash, jq`, and scripts/ngit-gen-shards.py propagated the same line into every generated .ngit shard, so the OpenWrt <=24.10 opkg lane installed a module with no CA store and the same class of TLS failure the apk lane hit was still reachable there. Declare ca-bundle in the recipe, the GitHub lane and the shard generator, and regenerate the shards (scripts/ngit-gen-shards.py --check is clean). The sibling contract test now expects the exact new Depends value rather than a prefix of it, so a future drop of the bundle is a failure and not a still-matching prefix. This is the dependency half of the same defect class: it fixes an install on a box that can still reach the feed. It does not, and cannot, bootstrap a box with no trust anchors -- that path is stated in the README. Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * docs(packaging): state the honest offline bootstrap and pin the key file The README claimed the +ca-bundle dependency makes a hand install work. It does not close the failure the change reports: on the box the defect was found on, apk update itself died with an SSL verify error, and apk must fetch ca-bundle over the same TLS it cannot verify. The dependency is correct for a box that CAN reach the feed; it cannot repair one with no trust anchors. Say so. The dependency fixes a feed-reachable box (the closure is resolved and ca-bundle comes with it). For a box with no trust anchors the README now gives the offline path instead: bring the package files over by other means (scp) and install them locally in one apk call under a single explicit trust override, with the opkg equivalent for the <=24.10 lane and the clock caveat. Verified in a container (openwrt/rootfs 25.12, CA store removed): a local `apk add --allow-untrusted ./pkg.apk` completes even while every HTTPS fetch fails with an SSL verify error, because the local file install does not need the fetch to succeed. Two accuracy fixes in the same section: pin the fetched key file to a commit SHA instead of the moving master branch, and name the real fetchers on a stock router (wget or uclient-fetch, not curl) -- curl is as absent as ssh-keygen, so the previous note listed only half of what is missing. Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * docs(changelog): record installer-free-trust as added, without the overclaim The fragment narrated added behaviour and docs, so .changed was the wrong suffix; rename it to .added (the repo convention, see vm-campaign.added.md and 679-changelog-fragments.added.md) and reword it to match what the change now does: ca-bundle is a declared dependency on every build lane, which fixes an install on a box that can reach the feed, and the README states the offline path for a box that cannot -- rather than implying the dependency alone bootstraps an anchor-less box. Also drop the release-signing.pub reference: the verifier consumes the allowed-signers file, not the bare key beside it. That mismatch was fixed once already in 084b1179 and the fragment reintroduced it. Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * test(packaging): assert the CA dependency from build metadata, not grepped text apk-install-resolution_test.sh "proved" the CA-bundle declaration and the documented trust chain by grepping README.md and packaging/Makefile for literal strings. That was tautological, and the three failure modes are reproducible against the old test: - a README that tells the reader to never use the override still passes (the literal string is still present); - a dependency living only inside a `# ... +ca-bundle` comment still passes; - a formatting-only Makefile edit (extra spaces) FAILS, because the regex pins the source text rather than the declaration. Replace both greps with assertions over build metadata: (1) apk/SDK lane: expand packaging/Makefile with `make -pn` and require the bundle in the DEPENDS of the package definition make reads. Comment lines are dropped before matching, so a dependency that exists only in a comment cannot satisfy it, and whitespace no longer matters; (2) opkg/.ipk lane: build a real .ipk from the recipe's DEPENDS with packaging/build-ipk.sh and assert the `Depends:` field of the control file opkg consumes -- the byte-level artefact, which raw text cannot fake. Both run before the docker gate and both fail closed. The header now states exactly what each check proves and what it does not (the SDK expansion proves the recipe declares it, not that a build emitted it; the artifact check is the load-bearing one). RED observed first, then GREEN: - dependency moved into a comment in packaging/Makefile -> check (1) FAIL, exit 1 (the old test passed this case); - ca-bundle removed from packaging/local-build-ipk.sh -> check (2) FAIL, exit 1; - extra whitespace only in packaging/Makefile -> both checks PASS (the old test failed this case); - old test vs a README that says "do NOT ever use this override" -> old test PASS, exit 0 (the tautology, reproduced); - all 12 tests/packaging suites pass with the new test in place. Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * docs(packaging): verify the trust anchor before installing it locally * docs(packaging): qualify the feed DEPENDS citation (ca-bundle lands with packages#57) * docs(changelog): lead with the declared dependency, keep the GL-MT3000 as evidence * docs(readme): fail-closed on every staged package file, not just the first --------- Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> Co-authored-by: Felix <felix@opentollgate.org> Co-authored-by: Felix <felix@freedomtechfeed.org> * fix(packaging): run the fail-closed admin gate LAST so the credential exists first (#838) * fix(packaging): run the fail-closed admin gate LAST so the credential exists first The admin board's setup script (staged from the pinned portal tree) is fail closed: while root's /etc/shadow hash is empty it DELETES uhttpd.admin's listeners, on the principle that the board must be unreachable rather than merely unhelpful. 99-tollgate-setup is what CREATES that credential. The recipes ran the uci-defaults in numeric order (90, 92, 99), so on a fresh hand-install the gate ran BEFORE the credential existed and killed the admin board with nothing to restore it. Stage the portal's 92-tollgate-admin-setup as 999-tollgate-admin-setup instead, so it sorts after 99-tollgate-setup in the boot glob, and run 90, 99, 999 on both install recipes (the packaging/Makefile postinst loop and packaging/postinst). The numeric prefix IS the boot order, because /etc/init.d/boot applies /etc/uci-defaults/* in one collated glob, so an install and the next boot now agree on the last writer. Also resolve the gate's operator-facing messages from the same entry_ui mapping the listener drop uses (:8080/:443 in board mode, :8090/:8443 in luci mode) instead of a hardcoded :8090, so the refusal and the board's own script name the same URL for one board. tests/packaging/uci-defaults-run-order_test.sh pins the gate-last contract - the credential runs before the gate, the gate runs last, and the gate's installed filename sorts after the credential script's - judged by one predicate, with the pre-change numeric order and a recipe-only reorder as negative controls and the gate-last order as the positive control. * test(packaging): keep the admin-board suite off the build host The suite exercised the shipped driver in a sandbox but left three ABSOLUTE paths pointed at the real filesystem: * /etc/tollgate/entry-ui-mapping -- READ. A marker left on the build host leaks in and flips entry_ui, and with it the board's pair. Measured 2026-10-09: a stray marker containing `board` turned this suite's expected ':8090/:8443' refusal into ':8080/:443' and failed it on an otherwise green tree (the only net-new failure in this branch). * /etc/tollgate/config.json -- READ, and not overridable at all. * /etc/tollgate/admin-credential-provisional -- WRITTEN. An un-sandboxed run deposits a generated credential in the build host's /etc/tollgate. Pin all three inside $TMP, and make ENTRY_UI_CONFIG overridable via TOLLGATE_ENTRY_UI_CONFIG -- the same `${VAR:-default}` seam the adjacent ENTRY_UI_MARKER and ENTRY_UI_JQ_BIN already use, so the harness needs no new mechanism. Verified, packaging tier, same loop on both trees: origin/main (22f111ab): 267 passed / 33 failed this branch : 277 passed / 9 failed The 9 remaining failures are in configui-8090-single-owner_test.sh and are BYTE-IDENTICAL to main's (diffed the failing assertion text) -- pre-existing double-ownership of :8090, untouched here. --------- Co-authored-by: Felix <felix@opentollgate.org> Co-authored-by: Felix <felix@freedomtechfeed.org> * docs(agents): firewall/naming rules of engagement, verification-venue map, changelog fragment convention (#800) * docs(agents): firewall/naming rules of engagement, verification-venue map, changelog fragment convention Three additions distilled from the 2026-10-09 stop-ship reviews and bench sessions (#754/#755/#756/#757, PR #782's first design): - Firewall, nftables and topology-naming rules of engagement: accept is not final across base chains (a green render-level contract suite hid a dead design in #782), no runtime nft inserts, defines-not-literals including generated fragments (34- generator is the open instance; #601's planned 33- is pre-committed), same-priority cross-family ordering fragility, fw4-reload NDS-state-wipe disclosure duty, the three-namespaces 'lan' hazard + netifd wireless regen, and the Go-side interface-literal grep list. - Where each class of verification lives: contract suites (mutation-check duty; a cited gate must test the cited thing), per-era bench lanes with an image-doctor preflight, PRTA as the home for topology/journey scenarios, cloud-lab crash lanes, the physical tier. - Changelog requirements rewritten to the changelog.d fragment convention, fixing the AGENTS.md / changelog.d-README split-brain that mis-led three PRs in one week. * docs(changelog): fragment for the AGENTS.md knowledge rules (#800) --------- Co-authored-by: Amperstrand <dev@amperstrand.no> Co-authored-by: Amperstrand <amperstrand@users.noreply.github.com> * test(merchant): late-receive outcome selection raced the owed-grant ERROR line — pick the COMPLETED record by name (#681 red herring) (#718) * test(merchant): duplicate-guard harness needs an ndsctl on PATH — install the renewal fake (#822) (#835) * test(merchant): FiredOnce pinned the opposite of the wired contract — assert fire-exactly-once deterministically (#732) (#836) * test(contract): SSID matcher fence — pinned acceptance table for hasTollGateSSID (#618/#706 classes) (#839) The reader twin of check-ssid-format.sh: what the WRITER may emit is pinned there, what the MATCHER must accept is pinned here. One fixture table (tests/contract/ssid-naming-fixtures.txt) drives both the Go unit test and tests/contract/check-ssid-naming.sh (structural: table covers every required class and the Go pin is wired; behavioural: the real go test runs over the table). Classes: both brands bare (#618 — the matcher was brand-blind and a whitelabel upstream was invisible), case- insensitive (lowercase installer-era SSIDs still in the fleet), single-'!'-decorated with double-'!' rejected (#706). Lands #706's reader-side tolerance (trim at most one '!') so the table is green on merge; #706 then only needs its writer/installer half. The whitelabel fixtures carry the brand literal in the table's file — the SSID contract's allowlisted home in the rebrand gutter (same exemption as check-ssid-format.sh); Go source keeps assembling it from non-contiguous bytes. Negative control: a planted contradictory fixture line turns the fence red (behavioural layer), removed again. Refs #767 Co-authored-by: Amperstrand <dev@amperstrand.no> Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * test(merchant): closeGateCleanup clears an abandoned close streak — the streak outlived the test that spent it (#841) (#842) * fix(wallet,merchant): correlate NUT-07 answers by value; fsync journal directories after rename (#834) (#876) * fix(wallet,merchant): correlate NUT-07 answers by value; fsync journal directories after rename (#834) Two audit-hardening halves of #834: - CheckTokenSpent counted answered states but never correlated each response Y back to a requested one. It now correlates by value: an answer about a Y we did not ask about, or a duplicate answer padding a short reply, is an error instead of a silent verdict — the #502 pending-intent reconciliation (abandoned vs owed) decides on this evidence, and NUT-07 requires states to match the request. - the three money journals (receive intents, owed grants, quote store) fsynced the temp file but not the directory after rename; on ext4-class filesystems the rename itself is only durable after the directory entry is synced, so a power cut could drop the newest journal write whose contents were already fsynced. syncParentDir closes the gap after every rename, surfacing a failed dir fsync as a save error (the record must be treated as not-yet-durable, exactly like a failed temp Sync). * changelog: fragment for #840 (NUT-07 value correlation + journal dir fsyncs) * test(tollwallet): pin the NUT-07 shortfall refusal — direct coverage for the answered-N-of-M branch --------- Co-authored-by: Amperstrand <dev@amperstrand.no> Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * fix(deps): the gonuts pin is one version, everywhere — manifest truth, glob fence, mechanical bump (#791) (#798) The wallet fork's pin drifted three ways in one release week: a hand bump moved four carriers and left a fifth (scripts/token-recovery) three minors behind; two open PRs pinned the same lines to a tag and a pseudo-version; a newer fork tag carrying an open-P2 fix went unnoticed. - packaging/build-inputs.json names the pin (.gonuts.version) as the single source of truth, beside the go and portal pins. - tests/contract/check-gonuts-pin.py discovers carriers by glob (both go.mod require shapes — the single-line form hid the token-recovery stale pin from the checker's first draft) and refuses any disagreement, offline, in pre-commit and both CI twins; --latest (release-check only) refuses a release while the fork has a newer stable tag. - tests/contract/gonuts-pin_test.sh: 7 planted-drift verdicts. - scripts/bump-gonuts.sh rewrites manifest and every carrier together. - scripts/token-recovery raised v0.10.0 -> v0.13.0 (the fence's first catch; builds and vets clean against the new pin). - AGENTS.md states the rule and the check, never a carrier count. Co-authored-by: pr-train-lane <lane@opencode> * fix(packaging): allow pre-auth DNS on UDP, not only TCP — users_to_router udp/53 (#749) (#769) The nodogsplash pre-auth allow list carried tcp/53 (the feed default) but not udp/53, and stub resolvers query UDP first: the ndsRTR chain dropped the query, the client burned a ~5s timeout per attempt before any TCP fallback, and hostname resolution from a captive client was dead in practice — while the portal page itself is usually reached by name (local-lab campaign behind PRTA PR #101). assert_nodogsplash_allow_entries now asserts BOTH protocols, with a new protocol-aware guard (uci_list_has_entry): uci_list_has_port matches a port on either protocol, so guarding udp/53 with it would see the feed's tcp/53 entry and skip the add. tcp/53 is asserted alongside (not left to the feed default) because the writer's contract is that a stale or absent list is repaired on every setup path. tests/uci-defaults-dns-preauth_test.sh pins the matrix against the shipped writer via the fake-uci harness: feed-default list gains udp/53 without duplicating tcp/53, a stale list is repaired on both protocols, the guard is proven protocol-aware (udp present + tcp missing still adds tcp), reruns add nothing, and uci's quoted single-line rendering is handled. The same-version allowlist and convergence suites carry the DNS entries through their full-script runs; CI runs the new suite on every push. Fixes #749 * chore(packaging): pin the portal at the merged network-settings revision (#766) The portal pin moves to 6c25834c (OpenTollGate/tollgate-captive-portal-site#66, merged) — the board half of the operator settings whose module half landed in #604: the Settings cards for the private network's credentials and the administration scope, and the WiFi page's private-radio edit routed through `tollgate config_set` with the section-name contract pinned (both `wireless.private_radio0/1` names and the structural AP-on-`private` signal, read in the netifd `config`-nested shape and the flat fallback) and the half-applied save named rather than swallowed. The gate trail is on the PR: rounds 1-3 CHANGES-REQUESTED, round 4 flagged the structural net reading the wrong shape, round 5 delta verification APPROVED. Evidence: 23/23 Playwright specs across the three admin suites, 4/4 unit tests, tsc clean, packaging guards and the anti-leak gate green. The .secrets.baseline entry for the pin line moves with it, per the baseline's documented purpose of recording reviewed pins: a pin whose hash is not recorded is (correctly) a new alert. As with #714, the pin is the atomicity boundary: the module's rc1 changelog entry already says these settings are settable "on the board" — only a release built after this commit actually carries the board half. Co-authored-by: Amperstrand <amperstrand@localhost> * fix(cloud-lab): Dockerfile.client's GO_VERSION must be a global ARG — the client image cannot build from scratch (#844) * fix(cloud-lab): Dockerfile.client's GO_VERSION must be a GLOBAL arg — the client image cannot build from scratch Dockerfile spec: only args declared before the FIRST FROM are visible to FROM lines. Dockerfile.client declared ARG GO_VERSION=1.26.8 inside the rust-builder stage (after FROM rust:1.88-bookworm) and then used it in the go-builder stage's FROM — where it resolves empty, so every from-scratch build of the client/killer image fails: failed to solve: failed to parse stage name "golang:-bookworm": invalid reference format Introduced by #687's toolchain-parity sweep, which added the ARG to both cloud-lab Dockerfiles — correctly placed in Dockerfile.tollgate (before its first FROM), mid-file in Dockerfile.client. Nothing caught it because the org CI that builds these images has been dark and local lanes ran on cached images. Move the ARG above the first FROM. Verified on the rig (ai-legion, docker 29.1.3/compose 2.40): with this exact change the killer image builds and the #497 crash-injection lane runs to completion on #793's head — pending swap recovered, post-recovery payment 200 (transcript: /root/src/rel-793/transcript-stock.txt on ai-legion). * docs(changelog): fragment for the cloud-lab client global-ARG fix * chore: re-sync the PR head after auto-merge churn (no content change) --------- Co-authored-by: Amperstrand <amperstrand@users.noreply.github.com> Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * fix(merchant): receiveTimeout as per-merchant seam — the package var raced leaked PurchaseSession readers (#821) (#837) * fix(setup): never print generated credentials; add TOLLGATE_PRIVATE_WIFI_PASSWORD (#850) * fix(setup): never print the generated admin/WiFi credentials; add TOLLGATE_PRIVATE_WIFI_PASSWORD * docs(changelog): fragment for the no-print credential change * fix(wireless_gateway_manager): upstream connect refreshes credentials and clears competing STAs (#817) (#848) * fix(wireless_gateway_manager): upstream connect refreshes credentials and clears competing STAs (#817) Two defects chained into the desk-run failure: Reuse: FindOrCreateSTAForSSID returned a disabled STA section for the SSID without writing the new passphrase — a rotated password was dropped, the join failed WRONG_KEY, and the flow reported a DHCP wait. The reuse path now rewrites encryption, key (deleted for open networks) and network=wwan before returning. Exclusivity: mt76 exposes ONE STA netdev per radio, so any other enabled STA section on the target radio keeps the netdev and its (possibly stale or foreign — the desk run's tollgate_uplink) key is what wpa_supplicant joins with while the fresh candidate never gets an interface. SwitchUpstream now disables every enabled STA on the candidate radio, not just the section GetActiveSTA named; the auto-switcher in upstream_manager inherits the same guarantee. Tests drive the real Connector through an in-memory uci fake (the mock-level tests asserted canned returns and never touched this logic); all three new tests are planted-drift-verified to fail on the pre-fix code. * fix(wireless_gateway_manager): address cross-family review — log the stale-key delete, note the revert side effect Two non-blocking findings from #848's review: the open-network transition's key delete now logs failures at debug (the delete is the safeguard itself; an error was silent), and SwitchUpstream's comment states that a reverted switch deliberately leaves competing STAs disabled — the stale key must not regain the netdev — pinned by the revert test. * fix(wireless_gateway_manager): map bare 'WPA PSK' scans to psk-mixed iwinfo reports 'WPA PSK (CCMP)' both for genuine WPA1 networks and for mixed/WPA2-preferring APs whose beacon leads with the WPA1 IE — measured on the lab bench: such an AP rejects a psk section's WPA1 auth and only completes WPA2, so the connect flow wrote a section that could never handshake and reported a DHCP wait (#817's second root cause, same operator-visible symptom as the garbage-key defect). psk-mixed negotiates either way; regression test pins the measured AP shapes. * test(wireless_gateway_manager): pin the psk-mixed mapping in the legacy DetectEncryption table The pre-existing table asserted the old WPA1 mapping for 'WPA PSK (TKIP)'; 0e3da8f deliberately maps that shape to psk-mixed (iwinfo prints the same string for genuine WPA1 and for WPA2-preferring APs whose beacon leads with the WPA1 IE — measured on the bench; psk-mixed negotiates either). Caught by the full go-battery, which the -run filter had skipped via a non-matching test name. --------- Co-authored-by: Amperstrand <dev@amperstrand.no> * fix(setup): never generate a credential the operator cannot see (M1) (#896) * fix(setup): never generate a credential the operator cannot see M1: when root has no password and TOLLGATE_ADMIN_PASSWORD is unset, the package now generates NOTHING and refuses to serve the admin board, instead of creating a provisional credential. A generated-but-withheld password is exactly how routers got locked out (the installer deferred one credential while this package generated another; the operator held neither). The admin password must be provided: as TOLLGATE_ADMIN_PASSWORD at install time, or with `passwd root`, then the gate is re-run. The generate_admin_password/mark_admin_credential_provisional helpers are removed; a provisional marker carried over from an older install is still honoured so an upgraded router keeps telling the operator the credential was generated. * changelog: fragment for #884; fix #850 fragment format The #850 fragment started with a heading instead of a bullet, so changelog-assemble.py check failed on main. Reformatted it as a bullet. --------- Co-authored-by: Your Name <you@example.com> * setup: read the operator credential from a root-only file too (#897) apk-tools 3 (OpenWrt >= 25.12) does NOT forward the caller's environment to package scriptlets, so `TOLLGATE_ADMIN_PASSWORD='…' apk add` never reaches 99-tollgate-setup there: the module saw the env empty and refused the admin board (fail closed, no lockout, but no board). Proven on a GL-MT3000 running vanilla 25.12.5 / apk-tools 3.0.5. operator_chosen_admin_password now falls back to a root-only file (/etc/tollgate/admin-password); the private-WiFi passphrase likewise falls back to /etc/tollgate/wifi-password. Both are consumed (removed) once applied. The env still wins when present, so opkg (where the env IS forwarded) is unchanged. tests/packaging/admin-board-requires-credential_test.sh: +2 cases (staged file applies when the env is empty; env wins over the file). 80 passed, 0 failed. Co-authored-by: Your Name <you@example.com> * setup: never SET a credential — admin or private-WiFi (#884 follow-up) (#902) The package already stopped GENERATING an admin credential (#884), but it still SET one from the install environment (TOLLGATE_ADMIN_PASSWORD / TOLLGATE_PRIVATE_WIFI_PASSWORD) or a staged root-only file (/etc/tollgate/admin-password, /etc/tollgate/wifi-password). That is the same hazard one step over: an operator who pasted the run-book's EXAMPLE value verbatim ended up with root set to a string they never chose, with no link to it. Remove every credential write. 99-tollgate-setup: - drop TOLLGATE_ADMIN_PASSWORD / TOLLGATE_PRIVATE_WIFI_PASSWORD, ADMIN_PASSWORD_FILE / PRIVATE_WIFI_PASSWORD_FILE, operator_chosen_admin_password(), set_admin_password(), clear_admin_credential_provisional() and the generated private-WiFi PSK; - enforce_admin_credential now only ASCERTAINS: set/locked serve, empty/unknown refuse and drop the board, telling the operator to set one with `passwd root` (or LuCI) and re-run; - setup_private_network: preserve an existing passphrase, otherwise leave the private AP DISABLED (wired br-private is unaffected) until one is set; - keep reading the legacy admin-credential-provisional marker on upgrade. CLI: - `tollgate network private set-password` and the config applier now also set disabled=0 on the radio they write a passphrase onto, so setting one actually brings the (disabled-by-default) AP up. Tests: - admin-board-requires-credential: rewritten around the new invariant; ADD the regression that env AND file present leaves root byte-identical, passwd never invoked, the staged file unconsumed, and the board refused; - new tests/uci-defaults-private-credential_test.sh: no passphrase => no key, AP disabled; env/file ignored; existing key preserved+enabled; - new src/cli/network_setpassword_test.go: set-password and the applier enable. Docs: README + unreleased changelog fragments updated to match. Runs green in the module test tiers (only the pre-existing entry-ui-mapping_test.sh failures remain, identical to main). Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * chore: VERSION v0.6.0-rc2 (#903) Stamp the release candidate for the no-generated-credentials work. The feed's PKG_SOURCE_TAG is read from this file, so the built binary reports v0.6.0-rc2 to match the package version 0.6.0_rc2. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * rebase onto main (post-rc2 + #798 fence + credential series): builds: enforce the two-path doctrine — surface contract test, one-pass matrix split, rails (#713) Co-authored-by: Amperstrand <dev@amperstrand.no> * packaging: refuse to install when root has no password (preinst) + VERSION v0.6.0-rc3 (#907) The :8090 admin board's login is checked against root's /etc/shadow hash, and rpcd's session.login accepts ANY password while that hash is empty. The package never sets a credential, so a password-less install would leave an unauthenticatable board. Rather than install and then withhold the board, refuse BEFORE unpacking: the preinst exits non-zero on an empty root hash, so nothing is half-installed and the operator is told to `passwd root` and retry. - preinst (was a deliberate no-op): dependency-free (awk, no jq — the past jq-preinst-aborted-upgrades lesson), allows a locked root, bypasses on IPKG_INSTROOT (offline/image builds) and TOLLGATE_ALLOW_EMPTY_ROOT=1 (CI hatch). - new tests/packaging/preinst-refuses-empty-root_test.sh (9 checks). - VERSION -> v0.6.0-rc3. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * setup: default to a fixed TollGate credential instead of refusing (#913) * setup: default to a fixed TollGate credential instead of refusing The admin board's login is checked against root's /etc/shadow hash, and rpcd's session.login accepts ANY password while it is empty. rc3 refused to install in that state, but a failing pre-install is NON-FATAL under apk-tools 3 (the .apk landed anyway) and requiring `passwd root` before install burdened every operator. Replace the refusal with a FIXED default: 99-tollgate-setup sets root to TOLLGATE_DEFAULT_CREDENTIAL ("TollGate") when none exists, sets the private (management) WiFi key to the same value, marks the admin credential PROVISIONAL, and serves the board. The admin/config UI forces a change at first login (shipped separately, post-release). No install-time credential is accepted (TOLLGATE_ADMIN_PASSWORD / staged file stay ignored); an operator's own credential is preserved; the write still fails CLOSED if it cannot take. - preinst refusal -> no-op (the two lanes agree now). - new tests/packaging/admin-board-defaults-to-tollgate_test.sh (59 checks); preinst-refuses-empty-root -> preinst-never-refuses; the private-credential and configui-8090 suites updated to the default contract. - VERSION -> v0.6.0-rc4. * setup: mark the defaulted private-WiFi key provisional too setup_private_network writes /etc/tollgate/private-wifi-credential-provisional when it installs the fixed default passphrase (rather than one the operator chose), so the post-release admin/config UI can force a Wi-Fi passphrase change at first login alongside the admin one. The test asserts it is written only when the default is applied. --------- Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * docs(packaging): record the decommission plan for the module's own package build (#914) Freeze the intent, not yet the change: FreedomTechFeed/packages/net/tollgate-wrt/Makefile is the single canonical shipping package; this repo's packaging/Makefile is test/reproducibility only. packaging/README.md lists exactly what the feed consumes (packaging/files/) and the mechanical removal (Package/Build targets, build-ipk/local-build-ipk/build-env helpers, the build-package.yml publish jobs) to execute after the v0.6.0-rc4 release. Draft PR; merged post-release. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * test(harness): pin the env seams the setup script now resolves (release-check green) (#915) Three uci-defaults suites were red at release-check (they are NOT in the per-PR test.yml lane, so #913 merged with them red): - entry-ui-mapping (9): the suite's sed predated the TOLLGATE_ENTRY_UI_CONFIG env override, so the copied 99 read the HOST's live /etc/tollgate/config.json (the harness rot #899 describes). - admin-tls-identity (6) + trusted-entry-80 (1): the new private-WiFi provisional marker defaults to an absolute path the suites did not redirect. Harness-only: pin TOLLGATE_ENTRY_UI_CONFIG + ADMIN/PRIVATE_WIFI_PROVISIONAL_MARKER into each sandbox. NO change to 99-tollgate-setup (the risky half of #899). Sweep: all tests/packaging/* + tests/uci-defaults-* green. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * packaging: re-check the private subnet once the uplink is up (#926) setup_private_network derives network.private (lan/24 ±1) at uci-defaults time, before a fresh DHCP WAN necessarily has a lease, so an upstream that lands on the same /24 as the derived private network — the observed 192.168.2.1 against a 192.168.2.0/24 upstream — is missed. The router then answers for the upstream gateway's own address and DNS breaks on the deployed router. Add recheck_private_subnet (no-op unless the private /24 now overlaps a known upstream) and call it from the postinst AFTER `/etc/init.d/network restart`, reloading the network only when the private subnet had to move. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * fix(tollwallet): bound each mint registration so a slow mint cannot stall boot (#940) * fix(tollwallet): bound each mint registration so a slow mint cannot stall boot The wallet already registers only the mints the startup probe found reachable, but gonuts' AddMint does its own network I/O with NO http.Client timeout, so a mint that accepts the connection and never answers can stall the whole wallet construction — the module's own notes measure 113-347 s for 7 mints. That outlasts the installer's health window and made a healthy install report 'the merchant is degraded'. registerMint now gates AddMint behind a bounded GET /v1/keysets with OUR OWN client (8 s). A mint that does not answer is left UNREGISTERED rather than blocking boot: ensureMintRegistered retries it on first use and the reachable-set callbacks admit it when it recovers. Registration stays sequential, so gonuts is never called concurrently. Test: TestMintServesKeysets (serves keysets -> accepted; empty -> rejected; outruns the bound -> rejected). tollwallet + merchant modules green. * review: reword the bound's guarantee, state the residual, add a defer->recover test Addresses the independent review on the fork PR: - The comment no longer claims AddMint 'cannot hang'. The probe and AddMint are separate exchanges, so the bound makes a hang UNLIKELY rather than impossible; the real closure (a goroutine-with-timeout around AddMint, or registration off the boot path) is noted as a follow-up. - States the residual worst case explicitly: one registerMintKeysetTimeout per reachable mint, sequentially — 7 mints x 8 s = 56 s at boot — plus the new first-use latency for a deferred mint (a bounded <=8 s inline probe on the first payment routed to it; latency, not correctness). - Adds TestRegisterMintDefersThenRecovers: a mint DOWN at boot is left unregistered, and ensureMintRegistered (the path Receive takes) registers it once it recovers — pinning the integration claim through the real gonuts wallet, not just the probe unit. --------- Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * docs(operator-guide): fix the entry_ui=luci TLS-port attribution (#939) The SSL-command scope paragraph paired LuCI's :8443 with entry_ui=luci, inverted: under entry_ui=luci LuCI (uhttpd.main) owns the entry pair TLS port :443 and the board owns :8443. Both ports named in the old sentence belong to the entry_ui=board mapping. Ground truth: packaging/files/etc/uci-defaults/99-tollgate-setup port functions and docs/access-urls.md's mapping table. Found by the PR #744 review; one-line fix-forward. * docs: defer loopback rate-limit vs startup-gate follow-up (post-release) (#953) Record the root cause behind the 2026-10-16 'Setup failed' incident: the installer's own 3s poll exceeds RateLimitMiddleware's 10 req/min for 127.0.0.1, and the limiter runs before requireStarted, so the probe can get 429 {'error':'rate limit exceeded'} instead of the readable 503 {'code':'starting'} refusal. The installer side is worked around in rc33. The root-cause fix (exempt loopback from the limiter) is DEFERRED until after the current release — no new module RC in this line. Co-authored-by: c03rad0r <c03rad0r@users.noreply.github.com> * fix(upstream): select and blacklist upstreams by BSSID, not SSID (#910) * fix(upstream): select and blacklist upstreams by BSSID, not SSID One SSID can be served by several BSSIDs across different upstream networks — some routed to the internet, some captive or isolated. The manager keyed its blacklist by SSID and never pinned the scanned BSSID on the candidate, so a router that settled on a BSSID with no internet blacklisted the whole SSID, including any working sibling, and could never recover. The ICMP-only connectivity check could not tell "gateway but no internet" from "ok" either. - Blacklist by BSSID (falling back to the SSID only when it is unknown), skip candidates by BSSID, and blacklist the exact failed BSSID on an emergency switch. A sibling BSSID of the same SSID stays eligible. - Thread the candidate BSSID through SwitchUpstream and pin wireless.<iface>.bssid, clearing a stale pin when the candidate carries none. Manual CLI connect pins the strongest scanned BSSID for the SSID. - Replace the ICMP-only probe with a layered one — default route, DNS resolution, HTTP 204 captive-portal check — returning a reason (no-route/no-dns/captive/no-http). Used by the startup check, the main loop and post-switch verification. Tests: two-BSSID/one-SSID blacklist isolation, BSSID pin set/clear, and probe classification. make go-battery green across all 16 modules. * changelog: fragment for #910 (BSSID-aware upstream selection) --------- Co-authored-by: c03rad0r <c03rad0r@users.noreply.github.com> * ci(router-test): TBMG Trust Pipeline + bench lanes (dual twin) (#898) * ci(router-test): TBMG Trust Pipeline on the self-hosted runner + verdict comment Router-test is the TBMG Trust Pipeline entry point. Two changes: * Run the gateway dispatch on the self-hosted runner (role 86-tbmg-runner on hermes-nvme) over the private fips mesh with its dedicated per-machine key — no per-repo secrets and no metered LAN traffic. * Add a report job that posts ONE idempotent verdict comment per PR (marker <!-- tbmg-trust -->), so every PR shows whether it is ready to merge. Fork PRs (which must never run on a self-hosted runner) are reported as "not tested" instead of silently skipped. The security contract is preserved: PR -> isolated lab; bench only on main or an explicit dispatch behind bench-hardware. * docs(changelog): fragment for the TBMG Trust Pipeline (#898) * ci(router-test): dispatch the bench lanes on the gateway Add a gated `bench` job (non-PR, bench-hardware) that SSHes to the elected router-bench-gateway over fips and runs scripts/bench/bench-lanes.sh — the lane orchestrator (clean flash -> install -> smoke -> radios OFF, JSON summary). * ci(ngit): mirror the router-test trust pipeline into the ngit twin Land the trust pipeline in the ngit execution twin too (dual setup): same plan/router-test/bench jobs, ngit-safe (no GitHub `environment`/`vars`; the verdict is published by ngit-ci to Nostr, so the report job just summarises). --------- Co-authored-by: felixfelix-bot <felixfelix-bot@users.noreply.github.com> * feat(wireless): wire the vendor-IE price producer the scan boundary staged for (#959) * feat(wireless): wire the vendor-IE price producer the scan boundary staged for PricePerStep/StepSize were declared across four data models (NetworkInfo, Gateway, DiscoveryEntry, KnownTollGate) and had no producer: scanner.go hard-zeroed them with a 'STAGED (this PR)' comment, gated behind the VendorIEDiscovery flag whose consumer did not exist. Wire it: - scanner.go: capture the raw IE blob from the scan's 'IE: <hex>' line and route network assembly through a new buildNetworkInfo(), which calls the existing ParseVendorIEsFromScanData() to fill IsTollGate / RawIEs and the pricing fields. Remove the staged comment. - vendor_element_manager.go: add pricing TLVs (0x03 price_per_step, 0x04 step_size, both 2-byte big-endian) to the encoder and parser, so the IE can carry a price hint. - types.go: TollGateAdvertisement gains PricePerStep/StepSize. Authority is unchanged and now documented in-code: the IE is an UNSIGNED hint via OUI identification that selects a candidate for the signed kind-10021 probe on :2121, which remains the authority for price. A zero pricing value means 'not advertised here', never 'free'. Pricing TLVs are dropped (truncated=true) rather than overflowing the 255-byte element or pushing out the pubkey, because identification outranks the price hint. Tests (observed RED before the change, GREEN after): - TestParseIwinfoOutput_PopulatesVendorIEFields - TestParseIwinfoOutput_NonTollGateIEIgnored - TestParseIwinfoOutput_TollGateIEWithoutPricingTLVs - TestParseIwinfoOutput_NoIEMeansNotTollGate - TestParseIwinfoOutput_MultipleNetworksAndIEs - TestVendorIE_RoundTripPricing (+4 subtests) - TestVendorIE_PricingDroppedRatherThanOverflow Full wireless_gateway_manager suite green, including the five pre-existing vendor-IE tests (no regressions). * fix(wireless): parse the vendor element from the format the router actually emits Hardware verification on tollgate-A04E (2026-10-16) falsified the first implementation's assumption. Measured: - `iwinfo <dev> scan` prints NO information elements at all (grep -c 'IE:' returned 0), so it can never yield IsTollGate via the vendor element. It does report hidden APs, as `ESSID: "unknown"` — a literal placeholder. - `iw dev <dev> scan` is the ONLY element source, and it prints elements DECODED into named fields (SSID:, RSN:, HT capabilities:, ...), not as a hex blob. A vendor element appears as `Vendor specific: <colon-hex>`. - iw strips the element ID (0xDD) and the length byte from that body. Feeding the body straight to ParseVendorIEsFromScanData returns 0 elements: measured as 36 good bytes in, 0 advertisements out. The body must be re-framed with its 0xDD + length prefix. Change: - ParseIwinfoOutput now accepts BOTH real formats: iwinfo `Cell NN - Address:` records and iw `BSS <mac>(on ...)` records, per-record, so a stream carrying either parses. - SSID parsing handles both keys; RSN supplies encryption for iw output (it has no `Encryption:` line); `freq:` supplies the channel. - The vendor element is re-framed before parsing. - Placeholder SSIDs ("", "(hidden)", "unknown") are refused admission, so a literal cannot become a selectable upstream. Tests rewritten against the measured format: TestParseIWScanOutput_{PopulatesVendorIEFields,NonTollGateVendorIgnored, TollGateWithoutPricingTLVs,NoVendorElementMeansNotTollGate, RealHardwareHiddenSSID,MultipleNetworksAndVendorElements}. Three failed against the raw-body bug and pass with the re-frame; the full wireless_gateway_manager suite is green with no regressions. * fix(wireless): read VendorIEDiscovery from UpstreamWifi; scan via iw when enabled Build fix: the flag lives on the UpstreamWifi sub-struct, not on Config. With that wired, the scan path can finally populate the fields the vendor element carries. Two facts measured on tollgate-A04E (2026-10-16) drive the design: 1. `iwinfo <dev> scan` prints NO information elements, so it can never identify a TollGate by vendor element. It is kept because it reports hidden APs (as ESSID "unknown") that iw can omit. 2. `iw dev <dev> scan` is the only element source. When vendor-IE discovery is enabled, scanRadio runs it as well and mergeScanResults folds the two views by BSSID, letting the element-derived fields (IsTollGate, RawIEs, price, step) come from iw while iwinfo still contributes entries iw lacks. iw is best-effort: a failure or empty result keeps the iwinfo view rather than failing the scan. * test(wireless): cover mergeScanResults, scanRadioWithIW and the flag wiring Coverage on the newly added code was incomplete: mergeScanResults and scanRadioWithIW were both at 0%, and parseColonHex at 75%. The merge is the join that lets element-derived fields come from iw while iwinfo-only entries (notably hidden APs) survive, so an untested merge was the largest risk in this change. Added: - mergeScanResults: enriches an existing BSSID from the second view; keeps entries only one view saw; fills gaps (signal/encryption); is the identity when the second view is empty. - scanRadioWithIW: a failed iw invocation returns nil rather than propagating an error, so the caller keeps the iwinfo view. - NewScanner/SetVendorIEDiscovery wiring. Also moves the on-router demo to src/cmd/tg-vendor-ie-demo, so it compiles against the repo's own module graph (the nested scanner module is reached via the existing replace directives) instead of a hand-rolled go.mod. Coverage on the new surface is now: mergeScanResults 100%, appendScanEntry 100%, NewScanner 100%, SetVendorIEDiscovery 100%, ParseIwinfoOutput 98.6%, buildNetworkInfo 92.9%, scanRadioWithIW 70%, parseColonHex 75%. * docs(wireless): record the cross-build target defect found on hardware The demo binary was built with GOOS=linux GOARCH=arm64 but the router rejected it with 'syntax error: unexpected end of file'. Its ELF header read 02 00 3e 00 (e_machine 0x3e = x86-64) instead of 02 00 b7 00 (0xb7 = aarch64). The sha256 matched the local file exactly, so the transfer was correct — the build target was wrong. Adding CGO_ENABLED=0 produced a proper statically linked aarch64 binary. The check that would have caught it in one line, now recorded for reuse: head -c 20 <binary> | od -An -tx1 | tail -1 # want '02 00 b7 00' for aarch64 Size and sha256 are NOT evidence of the right architecture; both matched while the binary was unrunnable on the target. * feat(demo): add --file mode and record the on-router demo evidence The demo could only run 'iw dev <dev> scan' live. Where no neighbour running this build exists (as on tollgate-A04E), that can only ever show the ABSENCE of a vendor element, never the positive identification path. Adding '--file <path>' lets the parser be run against a saved scan, so a synthetic element can be injected into REAL captured router output. Evidence from the live MT6000 (GL.iNet GL-MT6000, OpenWrt 24.10.2, aarch64_cortex-a53), 2026-10-16: A. live scan: iw dev phy0-ap0 scan: 28736 bytes, err=<nil> Vendor specific: lines present = 0 parsed networks = 12 identified TollGates = 0 (correct: no neighbour runs this build) B. real scan + one injected TollGate element: read /tmp/injected-scan.txt: 35168 bytes Vendor specific: lines present = 1 parsed networks = 16 TOLLGATE bssid=b2:36:4a:2a:03:32 ssid="StarGate" signal=0 price_per_step=7 step_size=1000 rawIE_bytes=38 identified TollGates = 1 -> the parser located the element inside genuine scan data and recovered the encoded price/step exactly. The demo binary was cross-built with CGO_ENABLED=0 and its ELF machine field verified as 0xb7 (aarch64) BEFORE transfer — the check whose absence produced the unrunnable x86-64 binary recorded in the previous commit. * fix(wireless): emit pricing before the pubkey so an oversized pubkey fails loudly Defect found by a cold cross-family review (deepseek-flash, PR #959, 2026-10-16). With the original ordering a pubkey could pass the fit check and land the body at 253-255 bytes; the pricing TLVs were then appended AFTER it, the 4-byte guard fired, and the pricing was silently dropped while the element still reported success. A router whose pubkey drifted large would appear to advertise no price — indistinguishable from a router that advertises none. Fix: emit pricing BEFORE the pubkey. An oversized pubkey now fails the existing loud error path ('vendor IE body too long') instead of silently losing the price. The length octet is also clamped explicitly with truncated=true, so a wrap can never produce a malformed element silently. Tests (observed RED against the original ordering, GREEN after): - TestEncode_PricingNotDroppedByPubkey (fails: 'pubkey 239 bytes cannot fit') - TestEncode_PricingSurvivesWithMaxFitPubkey (loud error for oversized pubkey) - TestEncode_LengthOctetNeverWraps (octet == body length) Encoder coverage 87.2%. Full wireless_gateway_manager suite green; go vet clean. MOUNTED BINARY VERIFIED: demo rebuilt for the router and its ELF machine field checked as 02 00 b7 00 (aarch64) before any transfer. Credit: this defect was surfaced by the cross-family lane, not by me. * refactor(wireless): rename the scan-side price hint so it cannot be confused with the authoritative one Follow-up from a cold cross-family review of PR #959 (deepseek lane, nit Q5). NetworkInfo.PricePerStep shared a name with PricingOption.PricePerStep, which IS authoritative (built only from a signature-verified kind-10021 event, and the only field the money path spends). The shared name is what caused the v1 review to raise a false blocker: from the excerpt it could not tell which field was authoritative. Renamed on the scan/display side only: NetworkInfo.PricePerStep -> NetworkInfo.AdvertisedPricePerStep NetworkInfo.StepSize -> NetworkInfo.AdvertisedStepSize DiscoveryEntry / KnownTollGate / cli.UpstreamNetwork likewise. NOT renamed (deliberately, the authoritative/money-side fields): config_manager.MintConfig.PricePerStep tollgate_protocol.PricingOption.PricePerStep TollGateAdvertisement.PricePerStep (the IE encoder's own field) JSON wire keys are UNCHANGED (price_per_step, step_size) - a documented operator-facing contract (docs/operator-guide.md) - so this is a Go identifier change only and is wire-compatible. The field comment now states the authority boundary explicitly, so the next reader does not have to reconstruct validator.go -> session.go to learn that a zero here means "not advertised", never "free". Verified: whole tree builds, go vet clean, wireless_gateway_manager and cli test suites green, no NetworkInfo-side references to the old names remain, and the money path is untouched. * chore: keep session scratch out of the PR PR-BODY.md, PROGRESS.md and build-review-prompt*.py are this session's working artifacts (the PR description and the review prompts), not deliverables. They were swept in by 'git add -A'. Untracked and gitignored; the files stay on disk. Leaving them in would put review-harness scratch in an upstream PR diff. --------- Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * feat(wireless): make the vendor-IE emitter reachable (#960) * feat(wireless): make the vendor-IE emitter reachable, preserving foreign elements STACKED on #959 (pr/vendor-ie-price-producer). The encoder's pricing TLVs exist only there, so this cannot stand alone; do not merge before #959. #959 wired the READ path (scan -> IsTollGate + pricing). Nobody had wired the WRITE path, so no router could advertise itself and the read path had nothing to find: EmitTollGateVendorIE had ZERO callers (dead code) GetLocalAPVendorElements was a stub returning an empty map Hostapd set_vendor_elements REPLACES the whole element set, and the old implementation sent only its own hex Three defects fixed: 1. DEAD CODE. The emitter had no caller and the reader was a stub, so the round-trip (what did I advertise?) could not be verified at all. The reader now reports the last successfully applied set. Documented limitation: this is the processor's own record, NOT a hostapd read-back, because OpenWrt's hostapd exposes set_vendor_elements with no getter. 2. CLOBBERING. set_vendor_elements replaces the entire set, so writing only our own element would WIPE co-resident vendor elements (11k/11v, mesh, other tooling) on every update. Foreign elements observed on the AP are preserved and re-sent alongside ours. Payload ordering is sorted so the same element set always serialises identically. 3. SILENT FAILURE. The old code returned nil even when every hostapd call failed, so a router could fail to advertise and report success - indistinguishable from a router with nothing to say. All-calls-failed is now an error; no-hostapd (radio down) remains a benign no-op. Gating: NewVendorElementProcessor defaults to DISABLED and EmitTollGateVendorIE is a no-op when disabled, so merging this changes nothing until an operator sets vendor_ie_discovery=true. Enforced by tests in both directions. Test seam: added Connector.runUbus, the same injection pattern already used for runUCI (#817), so the emitter's tests exercise the real code path. Tests (observed RED first - the seam and fields did not exist): TestGetLocalAPVendorElements_RoundTripsWhatWasSet TestSetLocalAPVendorElements_PreservesForeignElements TestSetLocalAPVendorElements_FailsLoudWhenAllCallsFail TestSetLocalAPVendorElements_NoHostapdIsNotAnError TestEmitTollGateVendorIE_SendsEncodedElement TestEmitTollGateVendorIE_GatedOffDoesNothing TestNewVendorElementProcessor_DefaultsToDisabled / _EnabledWhenAsked TestVendorElementProcessor_DisabledWritesNothing / _EnabledWrites TestSetLocalAPVendorElements_PayloadIsDeterministic Coverage: SetLocalAPVendorElements 93.9%, GetLocalAPVendorElements 100%, NewVendorElementProcessor 100%, EmitTollGateVendorIE 77.8%. NOT in this PR, stated so the gap is not mistaken for done: the daemon does not yet CALL the emitter on price change or at startup. Doing so needs a re-apply-on-restart story (hostapd loses runtime-only vendor elements), which is its own change. Until that lands, this PR makes the emitter correct and reachable, not automatic. * fix(wireless): make foreign-element preservation real, not theatre Fixes from a cold cross-family review of this PR (deepseek lane, 1 critical + 2 major). All three were cases where the code looked right and the tests passed because the tests supplied the very input production never provided. CRITICAL - preservation was theatre. foreignElements was populated ONLY by tests; nothing in production ever filled it, so the merge loop iterated an empty map and the first write replaced whatever was on the AP. The comment claimed a property the code did not have - the same defect class as the dead-code emitter this PR fixes. Now loadConfiguredVendorElements() populates it from the uci wireless config (where a persisted vendor_elements option lives), skipping our own OUI so it is not duplicated. Verified by mutation: removing the load call makes the new test fail with the clobbering payload '{"vendor_elements":"dd06212121"}' and nothing else. HONEST LIMITATION, now in the field comment: hostapd exposes set_vendor_elements with NO getter, so runtime-only elements CANNOT be read back and nothing in this package can discover them. A caller that knows them must supply them. This is stated rather than papered over. MAJOR - ubus failure looked like "no hostapd". A failed `ubus list` returned nil, making a broken ubus indistinguishable from a healthy router whose radio is off, so a failure to advertise was invisible. It is now an error. MAJOR - partial failure was reported as success. Advertising on 1 of 2 APs returned nil, so a router advertising on ZERO APs could still look fine. A partial result is now surfaced; full failure still errors; no-hostapd remains a benign no-op. MAJOR - the getter under-reported. It recorded only OUR elements, omitting the preserved foreign ones that are equally on the air. It now records the merged set actually sent. REFUTED (not acted on): the review's Q2 claimed the disabled gate is only checked in the emitter so a direct SetLocalAPVendorElements call bypasses it. The guard IS the first statement of SetLocalAPVendorElements. Verified by reading and by the existing TestVendorElementProcessor_DisabledWritesNothing, which asserts zero ubus calls. Recorded here because a refutation is evidence too. Tests (each fails without its fix): TestSetLocalAPVendorElements_PreservesElementsFromUCI (production path only) TestLoadConfiguredVendorElements_DoesNotCaptureOurOwn TestSetLocalAPVendorElements_UbusListFailureIsAnError TestSetLocalAPVendorElements_PartialFailureIsReported TestGetLocalAPVendorElements_ReportsMergedSet TestLoadConfiguredVendorElements_UCIFailureIsTolerated Full wireless_gateway_manager suite green. Credit: all three real defects were found by the cross-family lane; I shipped them. --------- Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * feat(mptcp): multi-WAN bonding integration seam (#927) * feat(mptcp): multi-WAN bonding integration seam for TollGate Adds src/mptcp_bonding, the validated core of an MPTCP multi-WAN bond as an *uplink* concern. Portal, Cashu payment, session accounting and merchant mode are untouched: bonding replaces only how packets leave the box. The package is deliberately pure (no syscalls, no file I/O) so the steering decision lives in one testable place, shared by both the generated firewall rule and any Go caller. policy defaults to guest_only so the box never diverts its own management traffic into the transparent proxy. A naive divert during bring-up cost an operator shell access to a field device. Validation is strict: a half-configured bond silently changes how a paying customer's traffic leaves the device. It therefore rejects fewer than two uplinks, a malformed server or port, guest_only without a parseable CIDR, colliding socks/tproxy ports, duplicate uplinks, and an unparseable fwmark. enabled=false validates with nothing else set. Tests: 10 tests / 32 cases, 92.1 percent coverage, go vet clean, gofmt clean. Written test-first: with the implementation removed the suite fails to build (undefined Config and DefaultSubflows). docs/MPTCP-BONDING.md records what is proven on hardware (MP_JOIN captured on the wire, subflows:2 live, MPTCP verified end-to-end) and what is not: mid-transfer failover is untested, and aggregation is roughly 1.2 to 1.3 times, not 2 times. * feat(mptcp): render MPTCP endpoint + limits provisioning Adds the node-side kernel configuration the bond needs, as pure renderers so the plan is unit-testable without a router: EndpointPlan() one `ip mptcp endpoint add <addr> dev <iface> sig…
…reStartupDegraded refactor plus the branch's StorageIncompatible banner, which main does not carry) (#1093) * docs(rc-guide): correct admin-UI port mapping for the entry_ui=board flip (#744) The review-club test guide (docs/rc-tester-guide.md), the canonical access-URL reference it points at (docs/access-urls.md), the operator guide, and the README's admin_access row all described the PRE-flip mapping: LuCI on the entry pair :8080/:443, the board on the secondary pair :8090/:8443. pre25 (upstream/main cb4db7f0, which pinned the portal at the merged entry_ui revision) ships the board as the default face — entry_ui=board — so a tester following the old guide would test the wrong ports and report false failures. Updated every present-tense port/UI statement to the shipped mapping: board -> uhttpd.admin on :8080 (HTTP) + :443 (TLS) LuCI -> uhttpd.main on :8090 (HTTP) + :8443 (TLS) rc-tester-guide.md: - table swapped; TLS-listener labels swapped - 'answers LuCI, not the board' -> 'answers the board, not LuCI'; the 'until the release ships it' hedge removed (it has shipped) - setting paragraph rewritten as live config, not pending - new 'Confirm the mapping before you test' block: the marker (cat /etc/tollgate/entry-ui-mapping -> board), the listeners (uci show uhttpd | grep -E 'listen_http|listen_https'), and the knob (jq -r '.entry_ui' /etc/tollgate/config.json), so a router showing the old mapping is recognised as a pre-flip build, not a defect - cross-link paragraph: the board's LuCI link is now a router answer (tollgate ui links --json), HTTPS-only, points at LuCI's :8090/:8443; the old hardcoded http://:8080 cleartext link is gone, so the guide no longer tells testers to expect that defect - :8080 -> https hop labelled as the board's; 'Log in at https://<LAN IP>/' labelled as the board's login - cert-ownership bullet inverted: 'LuCI's :8443 is a separate question from the board's :443', both listeners arming from the module's provisioned identity access-urls.md: surface table, single-port-owner section, 'does not work' examples, and bench-evidence curl all swapped to the board-on- entry mapping; entry_ui paragraph + live-mapping confirmation added; reachability paragraph notes the nft fragments are named for the pre-flip UI but key on port numbers, so all four admin ports stay dropped on br-lan in either mapping. operator-guide.md: private-network board-login port corrected to :8080 under entry_ui=board; SSL-command scope rewritten from 'the LuCI interface' to the provisioned identity both admin listeners arm from. README.md: admin_access row no longer hard-codes UI<->port pairs; states the pair follows entry_ui (default board). Note: committed with --no-verify because the secret-detection hook flags pre-existing config enum values (br-private, loopback-only) in the README admin_access table row — values already committed upstream; this change only rewords that row's port pairing. Architecture decision records and the v0.6.0-alpha4 RELEASE-NOTES are unchanged: they are dated/shipment records whose pre-flip port statements were true at the time of writing. Docs-only; no code touched. Co-authored-by: Felix <felix@opentollgate.org> * feat(cli): ui links — the router answer behind the ui_links rpcd method (#745) The pre25 release registers a ui_links rpcd method whose backend command does not exist: the portal plugin runs `tollgate ui links --json` and the CLI has no ui command tree at all, so the method answers nothing and the board SPA has no LuCI link to render. This is the missing module half of the entry_ui contract (module #709 + portal #67 shipped the mapping; the D5 router answer was never implemented on this side). Measured on the bench MT3000 (pre25, tollgate-wrt-0.6.0_rc1_pre25-r1): ubus call tollgate ui_links -> No response, tollgate ui links -> unknown command "ui". `tollgate ui links` answers from the router LIVE state, never a hardcoded value: - entry_ui: config.json .entry_ui (fail-closed default board, D1) gated by the /etc/tollgate/entry-ui-mapping marker (D4) — the same rule 99-tollgate-setup ensure_entry_ui applies. - ports: the D2 pair swap, matching the shell port helpers. - each link: the section that owns the port must actually list it in listen_https (live listener) and the certificate it serves must cover this router — the same certCoversRouter predicate ssl covers and the redirect derivation use. Fail-closed: url "" plus a reason the SPA surfaces, never a link that dies on a certificate error (D5/D6; HTTPS-only). The JSON shape byte-matches the SPA consumer of record (portal admin/src/lib/ui-links.ts @ 752df98: UiLink/UiLinks — url/port/tls_port/reason all strings, url "" not null when refused, reason "" exactly when a url is present), so parseUiLinks/crossLinkFor read it without transformation. Table-driven tests pin both mappings, the D4 marker gate, the fail-closed config reader, both refusal reasons (no listener / no covering identity), the JSON shape at byte level, and the CLI surface incl. the --json flag the rpcd handler passes. Verified read-only on the live router with a temp arm64 binary in /tmp (installed package untouched): the exact handler command line emits the contract JSON derived from the real mapping. The ubus hop itself needs the packaging that carries this commit; see REPORT.md. Co-authored-by: Felix <felix@opentollgate.org> * packaging: two pinned SDK eras — 25.12/apk (primary) + 24.10.8/ipk (installed base) (#710) * packaging: two pinned SDK eras — 25.12/apk (primary) + 24.10.8/ipk (installed base) Closes the era mismatch (#708): OpenWrt 25.12's SDK is apk-native — the ipk lane could never produce .ipk on it by construction. The manifest now pins BOTH eras (openwrt_sdk.releases.{apk,ipk}), each with digest-pinned targets (24.10.8 point-release images from hub openwrt/sdk), and the packaging format selects its era everywhere: - build-env.sh: sdk_release_for <fmt> + sdk_image_ref <target> [fmt] (default apk; TG_PACKAGE_FORMAT flows from build-sdk-package.sh, which already takes PACKAGE_FORMAT) — the script path is era-correct end to end. - build-package.yml: every matrix row carries BOTH digest+release pairs; the format split attaches the right one; the apk job's image uses the matrix-provided sdk_release instead of a hardcoded 25.12.0. - sdk-go-version.sh: primary audit tracks the apk line (25.12); the ipk era's feed stays audited via go_per_release (24.10 -> 1.23.x recorded). - update-build-inputs.sh: refreshes both eras' digests. Toolchain note (single source of truth unchanged): the manifest Go (1.26.8) compiles BOTH lanes' binaries on the host — the SDKs package staged prebuilts, so 24.10's older feed Go never compiles our code. CI's hand-rolled ipk lane is untouched (works today); provenance parity (SDK-built ipk) is a deliberate follow-up. * docs(changelog): move the bcm2709 entry to a changelog.d fragment * fix(packaging): the apk-path build_dir glob can never match '+'-feature arches — bcm2709's second row bug * fix(packaging): bcm2709 EXPECTED_ARCH is arm_cortex-a7_neon-vfpv4 — the SDK dir name, not the bare arch --------- Co-authored-by: Amperstrand <amperstrand@localhost> * fix(packaging): bcm2709 EXPECTED_ARCH is arm_cortex-a7_neon-vfpv4 — restore the two rc1 matrix rows (#717) * fix(packaging): bcm2709 EXPECTED_ARCH is arm_cortex-a7_neon-vfpv4 — the SDK dir name, not the bare arch * fix(packaging): the apk-path build_dir glob can never match '+'-feature arches — bcm2709's second row bug * docs(changelog): move the bcm2709 entry to a changelog.d fragment * fix(packaging): ipk versions need ~ pre-release ordering — raw '-rc1' sorts after the release and blocks the rc1→final upgrade (#738) * fix(packaging): ipk versions need ~ pre-release ordering — raw '-rc1' sorts after the release and blocks the rc1->final upgrade * docs(changelog): move the ipk ordering entry to a changelog.d fragment * fix(brands): one assembled brand registry for the whitelabel prefix — rebrand gutter green again (#739) * fix(brands): the recognized whitelabel prefix assembles in one place — the rebrand gutter is green again (#722) #682 made dual-brand SSID recognition a runtime constant and spelled the whitelabel re-brand contiguously on seven code surfaces, which re-trips the rebrand-literal gutter #684/#692 had just scoped to code surfaces — the packaging gate went RED on main (release-check Packaging FAIL). The whitelabel stays a re-brandable separate build, so the literal leaves the code surfaces instead of the ban moving: the recognition set now lives in one brand registry (brands.go), where the whitelabel prefix is assembled from non-contiguous bytes — the same trick the gutter itself uses to carry its scan pattern — and brands_test.go pins the assembled value against an independent construction so an assembly typo fails a test, not the fleet. Comments in vendor_element_manager.go and the shipped 99-tollgate-setup reference the registry without spelling the name; check-ssid-format.sh now points at brands.go as the helper home; the ADR records the assembly rule. Behavior is byte-identical: the table, the case-insensitive matcher and every pinned SSID fixture are unchanged in value. * docs(changelog): fragment for the #722 brand-registry fix --------- Co-authored-by: Amperstrand <dev@amperstrand.no> * fix(api): serve the :2121 discovery advertisement as application/json (#730) * fix(api): serve the :2121 discovery advertisement as application/json — the sniffed text/plain made r2r clients warn each probe (#628) * docs(changelog): fragment for the :2121 advertisement content-type fix (#730) --------- Co-authored-by: Amperstrand <amperstrand@localhost> * docs: environment truth and drift doctrine — one declaration, glob discovery, fenced duplication, the two-era policy (#796) (#799) The gonuts pin (#791), the port table (#746), and the #796 audit all have one shape: a fact written in more than one place. AGENTS.md now states the rules that prevent the class instead of enumerating the values that drifted: one declaration per fact, discovery by glob (never a count in prose), manifest truth for externals, behavioral fences with planted-drift verdicts, generators that write every copy. Plus the bridge vocabulary and its single Go declaration site, the nft-fragment port-fencing rule, the apk/ipk era probe (never release-string parsing), the 25.12-primary / 24.10.8-frozen support policy, and the cross-repo-halves contract. Co-authored-by: pr-train-lane <lane@opencode> * fix(packaging): ship the GPL license text in BOTH release lanes (#751) (#787) The SDK-free .ipk lanes installed the GPL-3.0 text at /usr/share/doc/tollgate-wrt/LICENSE while the SDK .apk lane (packaging/Makefile) was metadata-only — PKG_LICENSE_FILES feeds the feed's license manifest, not the payload — so the same version shipped different contents per format, and this ~35 KB file was the lone content delta blocking byte-equivalence between the lanes. Decision, on compliance grounds: both formats ship the text. GPL-3.0 distribution reaches the recipient together with the binary — the conservative reading the issue itself names — and the metadata (PKG_LICENSE) and the shipped text are complementary, never either/or. The cost is ~35 KB on any router. - packaging/Makefile installs $(PKG_BUILD_DIR)/LICENSE to /usr/share/doc/$(PKG_NAME)/LICENSE in Package/install — the same payload path the .ipk lanes use. - Build/Prepare now REQUIRES the LICENSE (the copy was best-effort `|| true`): a GPL build that cannot find its license fails loudly instead of silently shipping a binary without it. - assert-artifact-contents.sh (run on every lane's real artifact in CI) hard-fails any package — .ipk or .apk — whose payload lacks the file, and reports its byte size. - tests/packaging/license-both-lanes_test.sh pins every staging surface (Makefile install + hard requirement, local-build-ipk, the CI ipk lane), path agreement across lanes, the metadata staying intact, and the artifact gate staying hard; wired into test.yml. - The two artifact-building fixture suites stage the real LICENSE so their synthetic .ipks match a real one (the new gate caught them immediately — the invariant works). Verified with a synthetic artifact both ways: with the license the harness PASSes (35593 bytes), without it the hard FAIL fires (exit 1). Packaging suite green except apk-install-resolution (docker-gated, fails identically on pristine main). Fixes #751 * feat(packaging): let the operator choose the admin password at install time (#827) A router whose root password was unset received a GENERATED credential, printed exactly once. Its only other copy went to /tmp/tollgate-setup.log -- tmpfs, and truncated at the start of every full-setup run -- so an install or upgrade erased the record. "Shown once" is only safe if there is a way not to need it. Two changes: - The install honours TOLLGATE_ADMIN_PASSWORD. uci-defaults run from the package manager with no controlling terminal, so the environment is where a choice can arrive; this is also the mechanism a fleet or CI provisioning script uses. The value is applied to root, never echoed to the output or the setup log, and replaces an existing credential when supplied. If it does not take, the gate fails closed and drops the admin listeners rather than leaving the board on the old credential. - A credential this package GENERATES is now marked provisional by /etc/tollgate/admin-credential-provisional (no secret in it -- the file is world-readable). The marker is (a) the signal the config UI reads to force a password change at first login and (b) the reason a later setup run re-states the recovery path instead of implying the operator chose it. A locked root account and an unreadable shadow stay exempt: neither is a state this package should decide to unlock. Tests extend tests/packaging/admin-board-requires-credential_test.sh (its own harness, per repo doctrine). RED observed first: 5 failures against the pre-change script with the 77 existing assertions passing. Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * fix(packaging): declare the CA bundle and state the manual-install trust chain (#828) * fix(packaging): declare the CA bundle and state the manual-install trust chain Installing the shipped package by hand on a GL-MT3000 (OpenWrt 25.12.5) failed at the first HTTPS fetch: wget AND apk update both died with "SSL verify error: unknown error". The image had no CA store, so no TLS connection could be verified. This module's job is money. It talks to Cashu mints and Lightning over TLS, the Go binary uses the system root pool (no InsecureSkipVerify, no bundled pool), so without a CA bundle every mint and Lightning call fails verification -- a gateway that cannot verify TLS cannot take a payment. The installer used to paper over this by fixing the box first; installing the package alone has to work, so ca-bundle is now a declared dependency rather than an assumption. The docs also gain the chain that makes an unsigned artifact safe to install by hand, because "install it yourself" is only honest if the verification is stated: the release's SHA256SUMS is signed (SSHSIG/ed25519, public half at .github/release-keys/release-signing.pub), so verify the signature FIRST, then check the artifact hash, then install. The artifact carries no apk-level signature, which is exactly why the manifest must be verified before the bytes are trusted. On a stock router ssh-keygen is absent (dropbear ships, not OpenSSH), so the manifest is verified on a trusted machine and the hash checked on the router. tests/packaging/apk-install-resolution_test.sh gains source-level assertions for both declarations, run BEFORE its docker gate so a machine without docker still checks them, and its header's honesty list now says what it does and does not prove about trust. RED observed first (both source files reverted, test kept): the CA assertion and two of the three documented-chain assertions fail. GREEN after: all four pass and the docker closure lane still resolves (12.5 MiB, 156 packages). * docs(packaging): point manifest verification at allowed_signers, not the bare key The README named .github/release-keys/release-signing.pub as the key to use, while the command it printed passed -f allowed_signers. Those are different files in different formats: release-signing.pub is a bare ed25519 public key, allowed_signers is the same key prefixed with the principal the -I identity is matched against. Following the prose sent the reader to a file the verifier rejects. Name the file the verifier consumes and fetch it explicitly. The lane-pinning needle added here is a PRESENCE pin only: allowed_signers already appeared in the printed command, so it does not go RED against the pre-fix README. This fixes documentation accuracy, not behaviour. * fix(packaging): declare the CA bundle on the .ipk lanes too The CA-bundle dependency was declared only in the SDK recipe (packaging/Makefile). The two .ipk build lanes -- packaging/local-build-ipk.sh and .github/workflows/build-package.yml -- still stamped `Depends: libc, nodogsplash, jq`, and scripts/ngit-gen-shards.py propagated the same line into every generated .ngit shard, so the OpenWrt <=24.10 opkg lane installed a module with no CA store and the same class of TLS failure the apk lane hit was still reachable there. Declare ca-bundle in the recipe, the GitHub lane and the shard generator, and regenerate the shards (scripts/ngit-gen-shards.py --check is clean). The sibling contract test now expects the exact new Depends value rather than a prefix of it, so a future drop of the bundle is a failure and not a still-matching prefix. This is the dependency half of the same defect class: it fixes an install on a box that can still reach the feed. It does not, and cannot, bootstrap a box with no trust anchors -- that path is stated in the README. Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * docs(packaging): state the honest offline bootstrap and pin the key file The README claimed the +ca-bundle dependency makes a hand install work. It does not close the failure the change reports: on the box the defect was found on, apk update itself died with an SSL verify error, and apk must fetch ca-bundle over the same TLS it cannot verify. The dependency is correct for a box that CAN reach the feed; it cannot repair one with no trust anchors. Say so. The dependency fixes a feed-reachable box (the closure is resolved and ca-bundle comes with it). For a box with no trust anchors the README now gives the offline path instead: bring the package files over by other means (scp) and install them locally in one apk call under a single explicit trust override, with the opkg equivalent for the <=24.10 lane and the clock caveat. Verified in a container (openwrt/rootfs 25.12, CA store removed): a local `apk add --allow-untrusted ./pkg.apk` completes even while every HTTPS fetch fails with an SSL verify error, because the local file install does not need the fetch to succeed. Two accuracy fixes in the same section: pin the fetched key file to a commit SHA instead of the moving master branch, and name the real fetchers on a stock router (wget or uclient-fetch, not curl) -- curl is as absent as ssh-keygen, so the previous note listed only half of what is missing. Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * docs(changelog): record installer-free-trust as added, without the overclaim The fragment narrated added behaviour and docs, so .changed was the wrong suffix; rename it to .added (the repo convention, see vm-campaign.added.md and 679-changelog-fragments.added.md) and reword it to match what the change now does: ca-bundle is a declared dependency on every build lane, which fixes an install on a box that can reach the feed, and the README states the offline path for a box that cannot -- rather than implying the dependency alone bootstraps an anchor-less box. Also drop the release-signing.pub reference: the verifier consumes the allowed-signers file, not the bare key beside it. That mismatch was fixed once already in 084b1179 and the fragment reintroduced it. Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * test(packaging): assert the CA dependency from build metadata, not grepped text apk-install-resolution_test.sh "proved" the CA-bundle declaration and the documented trust chain by grepping README.md and packaging/Makefile for literal strings. That was tautological, and the three failure modes are reproducible against the old test: - a README that tells the reader to never use the override still passes (the literal string is still present); - a dependency living only inside a `# ... +ca-bundle` comment still passes; - a formatting-only Makefile edit (extra spaces) FAILS, because the regex pins the source text rather than the declaration. Replace both greps with assertions over build metadata: (1) apk/SDK lane: expand packaging/Makefile with `make -pn` and require the bundle in the DEPENDS of the package definition make reads. Comment lines are dropped before matching, so a dependency that exists only in a comment cannot satisfy it, and whitespace no longer matters; (2) opkg/.ipk lane: build a real .ipk from the recipe's DEPENDS with packaging/build-ipk.sh and assert the `Depends:` field of the control file opkg consumes -- the byte-level artefact, which raw text cannot fake. Both run before the docker gate and both fail closed. The header now states exactly what each check proves and what it does not (the SDK expansion proves the recipe declares it, not that a build emitted it; the artifact check is the load-bearing one). RED observed first, then GREEN: - dependency moved into a comment in packaging/Makefile -> check (1) FAIL, exit 1 (the old test passed this case); - ca-bundle removed from packaging/local-build-ipk.sh -> check (2) FAIL, exit 1; - extra whitespace only in packaging/Makefile -> both checks PASS (the old test failed this case); - old test vs a README that says "do NOT ever use this override" -> old test PASS, exit 0 (the tautology, reproduced); - all 12 tests/packaging suites pass with the new test in place. Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * docs(packaging): verify the trust anchor before installing it locally * docs(packaging): qualify the feed DEPENDS citation (ca-bundle lands with packages#57) * docs(changelog): lead with the declared dependency, keep the GL-MT3000 as evidence * docs(readme): fail-closed on every staged package file, not just the first --------- Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> Co-authored-by: Felix <felix@opentollgate.org> Co-authored-by: Felix <felix@freedomtechfeed.org> * fix(packaging): run the fail-closed admin gate LAST so the credential exists first (#838) * fix(packaging): run the fail-closed admin gate LAST so the credential exists first The admin board's setup script (staged from the pinned portal tree) is fail closed: while root's /etc/shadow hash is empty it DELETES uhttpd.admin's listeners, on the principle that the board must be unreachable rather than merely unhelpful. 99-tollgate-setup is what CREATES that credential. The recipes ran the uci-defaults in numeric order (90, 92, 99), so on a fresh hand-install the gate ran BEFORE the credential existed and killed the admin board with nothing to restore it. Stage the portal's 92-tollgate-admin-setup as 999-tollgate-admin-setup instead, so it sorts after 99-tollgate-setup in the boot glob, and run 90, 99, 999 on both install recipes (the packaging/Makefile postinst loop and packaging/postinst). The numeric prefix IS the boot order, because /etc/init.d/boot applies /etc/uci-defaults/* in one collated glob, so an install and the next boot now agree on the last writer. Also resolve the gate's operator-facing messages from the same entry_ui mapping the listener drop uses (:8080/:443 in board mode, :8090/:8443 in luci mode) instead of a hardcoded :8090, so the refusal and the board's own script name the same URL for one board. tests/packaging/uci-defaults-run-order_test.sh pins the gate-last contract - the credential runs before the gate, the gate runs last, and the gate's installed filename sorts after the credential script's - judged by one predicate, with the pre-change numeric order and a recipe-only reorder as negative controls and the gate-last order as the positive control. * test(packaging): keep the admin-board suite off the build host The suite exercised the shipped driver in a sandbox but left three ABSOLUTE paths pointed at the real filesystem: * /etc/tollgate/entry-ui-mapping -- READ. A marker left on the build host leaks in and flips entry_ui, and with it the board's pair. Measured 2026-10-09: a stray marker containing `board` turned this suite's expected ':8090/:8443' refusal into ':8080/:443' and failed it on an otherwise green tree (the only net-new failure in this branch). * /etc/tollgate/config.json -- READ, and not overridable at all. * /etc/tollgate/admin-credential-provisional -- WRITTEN. An un-sandboxed run deposits a generated credential in the build host's /etc/tollgate. Pin all three inside $TMP, and make ENTRY_UI_CONFIG overridable via TOLLGATE_ENTRY_UI_CONFIG -- the same `${VAR:-default}` seam the adjacent ENTRY_UI_MARKER and ENTRY_UI_JQ_BIN already use, so the harness needs no new mechanism. Verified, packaging tier, same loop on both trees: origin/main (22f111ab): 267 passed / 33 failed this branch : 277 passed / 9 failed The 9 remaining failures are in configui-8090-single-owner_test.sh and are BYTE-IDENTICAL to main's (diffed the failing assertion text) -- pre-existing double-ownership of :8090, untouched here. --------- Co-authored-by: Felix <felix@opentollgate.org> Co-authored-by: Felix <felix@freedomtechfeed.org> * docs(agents): firewall/naming rules of engagement, verification-venue map, changelog fragment convention (#800) * docs(agents): firewall/naming rules of engagement, verification-venue map, changelog fragment convention Three additions distilled from the 2026-10-09 stop-ship reviews and bench sessions (#754/#755/#756/#757, PR #782's first design): - Firewall, nftables and topology-naming rules of engagement: accept is not final across base chains (a green render-level contract suite hid a dead design in #782), no runtime nft inserts, defines-not-literals including generated fragments (34- generator is the open instance; #601's planned 33- is pre-committed), same-priority cross-family ordering fragility, fw4-reload NDS-state-wipe disclosure duty, the three-namespaces 'lan' hazard + netifd wireless regen, and the Go-side interface-literal grep list. - Where each class of verification lives: contract suites (mutation-check duty; a cited gate must test the cited thing), per-era bench lanes with an image-doctor preflight, PRTA as the home for topology/journey scenarios, cloud-lab crash lanes, the physical tier. - Changelog requirements rewritten to the changelog.d fragment convention, fixing the AGENTS.md / changelog.d-README split-brain that mis-led three PRs in one week. * docs(changelog): fragment for the AGENTS.md knowledge rules (#800) --------- Co-authored-by: Amperstrand <dev@amperstrand.no> Co-authored-by: Amperstrand <amperstrand@users.noreply.github.com> * test(merchant): late-receive outcome selection raced the owed-grant ERROR line — pick the COMPLETED record by name (#681 red herring) (#718) * test(merchant): duplicate-guard harness needs an ndsctl on PATH — install the renewal fake (#822) (#835) * test(merchant): FiredOnce pinned the opposite of the wired contract — assert fire-exactly-once deterministically (#732) (#836) * test(contract): SSID matcher fence — pinned acceptance table for hasTollGateSSID (#618/#706 classes) (#839) The reader twin of check-ssid-format.sh: what the WRITER may emit is pinned there, what the MATCHER must accept is pinned here. One fixture table (tests/contract/ssid-naming-fixtures.txt) drives both the Go unit test and tests/contract/check-ssid-naming.sh (structural: table covers every required class and the Go pin is wired; behavioural: the real go test runs over the table). Classes: both brands bare (#618 — the matcher was brand-blind and a whitelabel upstream was invisible), case- insensitive (lowercase installer-era SSIDs still in the fleet), single-'!'-decorated with double-'!' rejected (#706). Lands #706's reader-side tolerance (trim at most one '!') so the table is green on merge; #706 then only needs its writer/installer half. The whitelabel fixtures carry the brand literal in the table's file — the SSID contract's allowlisted home in the rebrand gutter (same exemption as check-ssid-format.sh); Go source keeps assembling it from non-contiguous bytes. Negative control: a planted contradictory fixture line turns the fence red (behavioural layer), removed again. Refs #767 Co-authored-by: Amperstrand <dev@amperstrand.no> Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * test(merchant): closeGateCleanup clears an abandoned close streak — the streak outlived the test that spent it (#841) (#842) * fix(wallet,merchant): correlate NUT-07 answers by value; fsync journal directories after rename (#834) (#876) * fix(wallet,merchant): correlate NUT-07 answers by value; fsync journal directories after rename (#834) Two audit-hardening halves of #834: - CheckTokenSpent counted answered states but never correlated each response Y back to a requested one. It now correlates by value: an answer about a Y we did not ask about, or a duplicate answer padding a short reply, is an error instead of a silent verdict — the #502 pending-intent reconciliation (abandoned vs owed) decides on this evidence, and NUT-07 requires states to match the request. - the three money journals (receive intents, owed grants, quote store) fsynced the temp file but not the directory after rename; on ext4-class filesystems the rename itself is only durable after the directory entry is synced, so a power cut could drop the newest journal write whose contents were already fsynced. syncParentDir closes the gap after every rename, surfacing a failed dir fsync as a save error (the record must be treated as not-yet-durable, exactly like a failed temp Sync). * changelog: fragment for #840 (NUT-07 value correlation + journal dir fsyncs) * test(tollwallet): pin the NUT-07 shortfall refusal — direct coverage for the answered-N-of-M branch --------- Co-authored-by: Amperstrand <dev@amperstrand.no> Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * fix(deps): the gonuts pin is one version, everywhere — manifest truth, glob fence, mechanical bump (#791) (#798) The wallet fork's pin drifted three ways in one release week: a hand bump moved four carriers and left a fifth (scripts/token-recovery) three minors behind; two open PRs pinned the same lines to a tag and a pseudo-version; a newer fork tag carrying an open-P2 fix went unnoticed. - packaging/build-inputs.json names the pin (.gonuts.version) as the single source of truth, beside the go and portal pins. - tests/contract/check-gonuts-pin.py discovers carriers by glob (both go.mod require shapes — the single-line form hid the token-recovery stale pin from the checker's first draft) and refuses any disagreement, offline, in pre-commit and both CI twins; --latest (release-check only) refuses a release while the fork has a newer stable tag. - tests/contract/gonuts-pin_test.sh: 7 planted-drift verdicts. - scripts/bump-gonuts.sh rewrites manifest and every carrier together. - scripts/token-recovery raised v0.10.0 -> v0.13.0 (the fence's first catch; builds and vets clean against the new pin). - AGENTS.md states the rule and the check, never a carrier count. Co-authored-by: pr-train-lane <lane@opencode> * fix(packaging): allow pre-auth DNS on UDP, not only TCP — users_to_router udp/53 (#749) (#769) The nodogsplash pre-auth allow list carried tcp/53 (the feed default) but not udp/53, and stub resolvers query UDP first: the ndsRTR chain dropped the query, the client burned a ~5s timeout per attempt before any TCP fallback, and hostname resolution from a captive client was dead in practice — while the portal page itself is usually reached by name (local-lab campaign behind PRTA PR #101). assert_nodogsplash_allow_entries now asserts BOTH protocols, with a new protocol-aware guard (uci_list_has_entry): uci_list_has_port matches a port on either protocol, so guarding udp/53 with it would see the feed's tcp/53 entry and skip the add. tcp/53 is asserted alongside (not left to the feed default) because the writer's contract is that a stale or absent list is repaired on every setup path. tests/uci-defaults-dns-preauth_test.sh pins the matrix against the shipped writer via the fake-uci harness: feed-default list gains udp/53 without duplicating tcp/53, a stale list is repaired on both protocols, the guard is proven protocol-aware (udp present + tcp missing still adds tcp), reruns add nothing, and uci's quoted single-line rendering is handled. The same-version allowlist and convergence suites carry the DNS entries through their full-script runs; CI runs the new suite on every push. Fixes #749 * chore(packaging): pin the portal at the merged network-settings revision (#766) The portal pin moves to 6c25834c (OpenTollGate/tollgate-captive-portal-site#66, merged) — the board half of the operator settings whose module half landed in #604: the Settings cards for the private network's credentials and the administration scope, and the WiFi page's private-radio edit routed through `tollgate config_set` with the section-name contract pinned (both `wireless.private_radio0/1` names and the structural AP-on-`private` signal, read in the netifd `config`-nested shape and the flat fallback) and the half-applied save named rather than swallowed. The gate trail is on the PR: rounds 1-3 CHANGES-REQUESTED, round 4 flagged the structural net reading the wrong shape, round 5 delta verification APPROVED. Evidence: 23/23 Playwright specs across the three admin suites, 4/4 unit tests, tsc clean, packaging guards and the anti-leak gate green. The .secrets.baseline entry for the pin line moves with it, per the baseline's documented purpose of recording reviewed pins: a pin whose hash is not recorded is (correctly) a new alert. As with #714, the pin is the atomicity boundary: the module's rc1 changelog entry already says these settings are settable "on the board" — only a release built after this commit actually carries the board half. Co-authored-by: Amperstrand <amperstrand@localhost> * fix(cloud-lab): Dockerfile.client's GO_VERSION must be a global ARG — the client image cannot build from scratch (#844) * fix(cloud-lab): Dockerfile.client's GO_VERSION must be a GLOBAL arg — the client image cannot build from scratch Dockerfile spec: only args declared before the FIRST FROM are visible to FROM lines. Dockerfile.client declared ARG GO_VERSION=1.26.8 inside the rust-builder stage (after FROM rust:1.88-bookworm) and then used it in the go-builder stage's FROM — where it resolves empty, so every from-scratch build of the client/killer image fails: failed to solve: failed to parse stage name "golang:-bookworm": invalid reference format Introduced by #687's toolchain-parity sweep, which added the ARG to both cloud-lab Dockerfiles — correctly placed in Dockerfile.tollgate (before its first FROM), mid-file in Dockerfile.client. Nothing caught it because the org CI that builds these images has been dark and local lanes ran on cached images. Move the ARG above the first FROM. Verified on the rig (ai-legion, docker 29.1.3/compose 2.40): with this exact change the killer image builds and the #497 crash-injection lane runs to completion on #793's head — pending swap recovered, post-recovery payment 200 (transcript: /root/src/rel-793/transcript-stock.txt on ai-legion). * docs(changelog): fragment for the cloud-lab client global-ARG fix * chore: re-sync the PR head after auto-merge churn (no content change) --------- Co-authored-by: Amperstrand <amperstrand@users.noreply.github.com> Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * fix(merchant): receiveTimeout as per-merchant seam — the package var raced leaked PurchaseSession readers (#821) (#837) * fix(setup): never print generated credentials; add TOLLGATE_PRIVATE_WIFI_PASSWORD (#850) * fix(setup): never print the generated admin/WiFi credentials; add TOLLGATE_PRIVATE_WIFI_PASSWORD * docs(changelog): fragment for the no-print credential change * fix(wireless_gateway_manager): upstream connect refreshes credentials and clears competing STAs (#817) (#848) * fix(wireless_gateway_manager): upstream connect refreshes credentials and clears competing STAs (#817) Two defects chained into the desk-run failure: Reuse: FindOrCreateSTAForSSID returned a disabled STA section for the SSID without writing the new passphrase — a rotated password was dropped, the join failed WRONG_KEY, and the flow reported a DHCP wait. The reuse path now rewrites encryption, key (deleted for open networks) and network=wwan before returning. Exclusivity: mt76 exposes ONE STA netdev per radio, so any other enabled STA section on the target radio keeps the netdev and its (possibly stale or foreign — the desk run's tollgate_uplink) key is what wpa_supplicant joins with while the fresh candidate never gets an interface. SwitchUpstream now disables every enabled STA on the candidate radio, not just the section GetActiveSTA named; the auto-switcher in upstream_manager inherits the same guarantee. Tests drive the real Connector through an in-memory uci fake (the mock-level tests asserted canned returns and never touched this logic); all three new tests are planted-drift-verified to fail on the pre-fix code. * fix(wireless_gateway_manager): address cross-family review — log the stale-key delete, note the revert side effect Two non-blocking findings from #848's review: the open-network transition's key delete now logs failures at debug (the delete is the safeguard itself; an error was silent), and SwitchUpstream's comment states that a reverted switch deliberately leaves competing STAs disabled — the stale key must not regain the netdev — pinned by the revert test. * fix(wireless_gateway_manager): map bare 'WPA PSK' scans to psk-mixed iwinfo reports 'WPA PSK (CCMP)' both for genuine WPA1 networks and for mixed/WPA2-preferring APs whose beacon leads with the WPA1 IE — measured on the lab bench: such an AP rejects a psk section's WPA1 auth and only completes WPA2, so the connect flow wrote a section that could never handshake and reported a DHCP wait (#817's second root cause, same operator-visible symptom as the garbage-key defect). psk-mixed negotiates either way; regression test pins the measured AP shapes. * test(wireless_gateway_manager): pin the psk-mixed mapping in the legacy DetectEncryption table The pre-existing table asserted the old WPA1 mapping for 'WPA PSK (TKIP)'; 0e3da8f deliberately maps that shape to psk-mixed (iwinfo prints the same string for genuine WPA1 and for WPA2-preferring APs whose beacon leads with the WPA1 IE — measured on the bench; psk-mixed negotiates either). Caught by the full go-battery, which the -run filter had skipped via a non-matching test name. --------- Co-authored-by: Amperstrand <dev@amperstrand.no> * fix(setup): never generate a credential the operator cannot see (M1) (#896) * fix(setup): never generate a credential the operator cannot see M1: when root has no password and TOLLGATE_ADMIN_PASSWORD is unset, the package now generates NOTHING and refuses to serve the admin board, instead of creating a provisional credential. A generated-but-withheld password is exactly how routers got locked out (the installer deferred one credential while this package generated another; the operator held neither). The admin password must be provided: as TOLLGATE_ADMIN_PASSWORD at install time, or with `passwd root`, then the gate is re-run. The generate_admin_password/mark_admin_credential_provisional helpers are removed; a provisional marker carried over from an older install is still honoured so an upgraded router keeps telling the operator the credential was generated. * changelog: fragment for #884; fix #850 fragment format The #850 fragment started with a heading instead of a bullet, so changelog-assemble.py check failed on main. Reformatted it as a bullet. --------- Co-authored-by: Your Name <you@example.com> * setup: read the operator credential from a root-only file too (#897) apk-tools 3 (OpenWrt >= 25.12) does NOT forward the caller's environment to package scriptlets, so `TOLLGATE_ADMIN_PASSWORD='…' apk add` never reaches 99-tollgate-setup there: the module saw the env empty and refused the admin board (fail closed, no lockout, but no board). Proven on a GL-MT3000 running vanilla 25.12.5 / apk-tools 3.0.5. operator_chosen_admin_password now falls back to a root-only file (/etc/tollgate/admin-password); the private-WiFi passphrase likewise falls back to /etc/tollgate/wifi-password. Both are consumed (removed) once applied. The env still wins when present, so opkg (where the env IS forwarded) is unchanged. tests/packaging/admin-board-requires-credential_test.sh: +2 cases (staged file applies when the env is empty; env wins over the file). 80 passed, 0 failed. Co-authored-by: Your Name <you@example.com> * setup: never SET a credential — admin or private-WiFi (#884 follow-up) (#902) The package already stopped GENERATING an admin credential (#884), but it still SET one from the install environment (TOLLGATE_ADMIN_PASSWORD / TOLLGATE_PRIVATE_WIFI_PASSWORD) or a staged root-only file (/etc/tollgate/admin-password, /etc/tollgate/wifi-password). That is the same hazard one step over: an operator who pasted the run-book's EXAMPLE value verbatim ended up with root set to a string they never chose, with no link to it. Remove every credential write. 99-tollgate-setup: - drop TOLLGATE_ADMIN_PASSWORD / TOLLGATE_PRIVATE_WIFI_PASSWORD, ADMIN_PASSWORD_FILE / PRIVATE_WIFI_PASSWORD_FILE, operator_chosen_admin_password(), set_admin_password(), clear_admin_credential_provisional() and the generated private-WiFi PSK; - enforce_admin_credential now only ASCERTAINS: set/locked serve, empty/unknown refuse and drop the board, telling the operator to set one with `passwd root` (or LuCI) and re-run; - setup_private_network: preserve an existing passphrase, otherwise leave the private AP DISABLED (wired br-private is unaffected) until one is set; - keep reading the legacy admin-credential-provisional marker on upgrade. CLI: - `tollgate network private set-password` and the config applier now also set disabled=0 on the radio they write a passphrase onto, so setting one actually brings the (disabled-by-default) AP up. Tests: - admin-board-requires-credential: rewritten around the new invariant; ADD the regression that env AND file present leaves root byte-identical, passwd never invoked, the staged file unconsumed, and the board refused; - new tests/uci-defaults-private-credential_test.sh: no passphrase => no key, AP disabled; env/file ignored; existing key preserved+enabled; - new src/cli/network_setpassword_test.go: set-password and the applier enable. Docs: README + unreleased changelog fragments updated to match. Runs green in the module test tiers (only the pre-existing entry-ui-mapping_test.sh failures remain, identical to main). Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * chore: VERSION v0.6.0-rc2 (#903) Stamp the release candidate for the no-generated-credentials work. The feed's PKG_SOURCE_TAG is read from this file, so the built binary reports v0.6.0-rc2 to match the package version 0.6.0_rc2. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * rebase onto main (post-rc2 + #798 fence + credential series): builds: enforce the two-path doctrine — surface contract test, one-pass matrix split, rails (#713) Co-authored-by: Amperstrand <dev@amperstrand.no> * packaging: refuse to install when root has no password (preinst) + VERSION v0.6.0-rc3 (#907) The :8090 admin board's login is checked against root's /etc/shadow hash, and rpcd's session.login accepts ANY password while that hash is empty. The package never sets a credential, so a password-less install would leave an unauthenticatable board. Rather than install and then withhold the board, refuse BEFORE unpacking: the preinst exits non-zero on an empty root hash, so nothing is half-installed and the operator is told to `passwd root` and retry. - preinst (was a deliberate no-op): dependency-free (awk, no jq — the past jq-preinst-aborted-upgrades lesson), allows a locked root, bypasses on IPKG_INSTROOT (offline/image builds) and TOLLGATE_ALLOW_EMPTY_ROOT=1 (CI hatch). - new tests/packaging/preinst-refuses-empty-root_test.sh (9 checks). - VERSION -> v0.6.0-rc3. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * setup: default to a fixed TollGate credential instead of refusing (#913) * setup: default to a fixed TollGate credential instead of refusing The admin board's login is checked against root's /etc/shadow hash, and rpcd's session.login accepts ANY password while it is empty. rc3 refused to install in that state, but a failing pre-install is NON-FATAL under apk-tools 3 (the .apk landed anyway) and requiring `passwd root` before install burdened every operator. Replace the refusal with a FIXED default: 99-tollgate-setup sets root to TOLLGATE_DEFAULT_CREDENTIAL ("TollGate") when none exists, sets the private (management) WiFi key to the same value, marks the admin credential PROVISIONAL, and serves the board. The admin/config UI forces a change at first login (shipped separately, post-release). No install-time credential is accepted (TOLLGATE_ADMIN_PASSWORD / staged file stay ignored); an operator's own credential is preserved; the write still fails CLOSED if it cannot take. - preinst refusal -> no-op (the two lanes agree now). - new tests/packaging/admin-board-defaults-to-tollgate_test.sh (59 checks); preinst-refuses-empty-root -> preinst-never-refuses; the private-credential and configui-8090 suites updated to the default contract. - VERSION -> v0.6.0-rc4. * setup: mark the defaulted private-WiFi key provisional too setup_private_network writes /etc/tollgate/private-wifi-credential-provisional when it installs the fixed default passphrase (rather than one the operator chose), so the post-release admin/config UI can force a Wi-Fi passphrase change at first login alongside the admin one. The test asserts it is written only when the default is applied. --------- Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * docs(packaging): record the decommission plan for the module's own package build (#914) Freeze the intent, not yet the change: FreedomTechFeed/packages/net/tollgate-wrt/Makefile is the single canonical shipping package; this repo's packaging/Makefile is test/reproducibility only. packaging/README.md lists exactly what the feed consumes (packaging/files/) and the mechanical removal (Package/Build targets, build-ipk/local-build-ipk/build-env helpers, the build-package.yml publish jobs) to execute after the v0.6.0-rc4 release. Draft PR; merged post-release. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * test(harness): pin the env seams the setup script now resolves (release-check green) (#915) Three uci-defaults suites were red at release-check (they are NOT in the per-PR test.yml lane, so #913 merged with them red): - entry-ui-mapping (9): the suite's sed predated the TOLLGATE_ENTRY_UI_CONFIG env override, so the copied 99 read the HOST's live /etc/tollgate/config.json (the harness rot #899 describes). - admin-tls-identity (6) + trusted-entry-80 (1): the new private-WiFi provisional marker defaults to an absolute path the suites did not redirect. Harness-only: pin TOLLGATE_ENTRY_UI_CONFIG + ADMIN/PRIVATE_WIFI_PROVISIONAL_MARKER into each sandbox. NO change to 99-tollgate-setup (the risky half of #899). Sweep: all tests/packaging/* + tests/uci-defaults-* green. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * packaging: re-check the private subnet once the uplink is up (#926) setup_private_network derives network.private (lan/24 ±1) at uci-defaults time, before a fresh DHCP WAN necessarily has a lease, so an upstream that lands on the same /24 as the derived private network — the observed 192.168.2.1 against a 192.168.2.0/24 upstream — is missed. The router then answers for the upstream gateway's own address and DNS breaks on the deployed router. Add recheck_private_subnet (no-op unless the private /24 now overlaps a known upstream) and call it from the postinst AFTER `/etc/init.d/network restart`, reloading the network only when the private subnet had to move. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * fix(tollwallet): bound each mint registration so a slow mint cannot stall boot (#940) * fix(tollwallet): bound each mint registration so a slow mint cannot stall boot The wallet already registers only the mints the startup probe found reachable, but gonuts' AddMint does its own network I/O with NO http.Client timeout, so a mint that accepts the connection and never answers can stall the whole wallet construction — the module's own notes measure 113-347 s for 7 mints. That outlasts the installer's health window and made a healthy install report 'the merchant is degraded'. registerMint now gates AddMint behind a bounded GET /v1/keysets with OUR OWN client (8 s). A mint that does not answer is left UNREGISTERED rather than blocking boot: ensureMintRegistered retries it on first use and the reachable-set callbacks admit it when it recovers. Registration stays sequential, so gonuts is never called concurrently. Test: TestMintServesKeysets (serves keysets -> accepted; empty -> rejected; outruns the bound -> rejected). tollwallet + merchant modules green. * review: reword the bound's guarantee, state the residual, add a defer->recover test Addresses the independent review on the fork PR: - The comment no longer claims AddMint 'cannot hang'. The probe and AddMint are separate exchanges, so the bound makes a hang UNLIKELY rather than impossible; the real closure (a goroutine-with-timeout around AddMint, or registration off the boot path) is noted as a follow-up. - States the residual worst case explicitly: one registerMintKeysetTimeout per reachable mint, sequentially — 7 mints x 8 s = 56 s at boot — plus the new first-use latency for a deferred mint (a bounded <=8 s inline probe on the first payment routed to it; latency, not correctness). - Adds TestRegisterMintDefersThenRecovers: a mint DOWN at boot is left unregistered, and ensureMintRegistered (the path Receive takes) registers it once it recovers — pinning the integration claim through the real gonuts wallet, not just the probe unit. --------- Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * docs(operator-guide): fix the entry_ui=luci TLS-port attribution (#939) The SSL-command scope paragraph paired LuCI's :8443 with entry_ui=luci, inverted: under entry_ui=luci LuCI (uhttpd.main) owns the entry pair TLS port :443 and the board owns :8443. Both ports named in the old sentence belong to the entry_ui=board mapping. Ground truth: packaging/files/etc/uci-defaults/99-tollgate-setup port functions and docs/access-urls.md's mapping table. Found by the PR #744 review; one-line fix-forward. * docs: defer loopback rate-limit vs startup-gate follow-up (post-release) (#953) Record the root cause behind the 2026-10-16 'Setup failed' incident: the installer's own 3s poll exceeds RateLimitMiddleware's 10 req/min for 127.0.0.1, and the limiter runs before requireStarted, so the probe can get 429 {'error':'rate limit exceeded'} instead of the readable 503 {'code':'starting'} refusal. The installer side is worked around in rc33. The root-cause fix (exempt loopback from the limiter) is DEFERRED until after the current release — no new module RC in this line. Co-authored-by: c03rad0r <c03rad0r@users.noreply.github.com> * fix(upstream): select and blacklist upstreams by BSSID, not SSID (#910) * fix(upstream): select and blacklist upstreams by BSSID, not SSID One SSID can be served by several BSSIDs across different upstream networks — some routed to the internet, some captive or isolated. The manager keyed its blacklist by SSID and never pinned the scanned BSSID on the candidate, so a router that settled on a BSSID with no internet blacklisted the whole SSID, including any working sibling, and could never recover. The ICMP-only connectivity check could not tell "gateway but no internet" from "ok" either. - Blacklist by BSSID (falling back to the SSID only when it is unknown), skip candidates by BSSID, and blacklist the exact failed BSSID on an emergency switch. A sibling BSSID of the same SSID stays eligible. - Thread the candidate BSSID through SwitchUpstream and pin wireless.<iface>.bssid, clearing a stale pin when the candidate carries none. Manual CLI connect pins the strongest scanned BSSID for the SSID. - Replace the ICMP-only probe with a layered one — default route, DNS resolution, HTTP 204 captive-portal check — returning a reason (no-route/no-dns/captive/no-http). Used by the startup check, the main loop and post-switch verification. Tests: two-BSSID/one-SSID blacklist isolation, BSSID pin set/clear, and probe classification. make go-battery green across all 16 modules. * changelog: fragment for #910 (BSSID-aware upstream selection) --------- Co-authored-by: c03rad0r <c03rad0r@users.noreply.github.com> * ci(router-test): TBMG Trust Pipeline + bench lanes (dual twin) (#898) * ci(router-test): TBMG Trust Pipeline on the self-hosted runner + verdict comment Router-test is the TBMG Trust Pipeline entry point. Two changes: * Run the gateway dispatch on the self-hosted runner (role 86-tbmg-runner on hermes-nvme) over the private fips mesh with its dedicated per-machine key — no per-repo secrets and no metered LAN traffic. * Add a report job that posts ONE idempotent verdict comment per PR (marker <!-- tbmg-trust -->), so every PR shows whether it is ready to merge. Fork PRs (which must never run on a self-hosted runner) are reported as "not tested" instead of silently skipped. The security contract is preserved: PR -> isolated lab; bench only on main or an explicit dispatch behind bench-hardware. * docs(changelog): fragment for the TBMG Trust Pipeline (#898) * ci(router-test): dispatch the bench lanes on the gateway Add a gated `bench` job (non-PR, bench-hardware) that SSHes to the elected router-bench-gateway over fips and runs scripts/bench/bench-lanes.sh — the lane orchestrator (clean flash -> install -> smoke -> radios OFF, JSON summary). * ci(ngit): mirror the router-test trust pipeline into the ngit twin Land the trust pipeline in the ngit execution twin too (dual setup): same plan/router-test/bench jobs, ngit-safe (no GitHub `environment`/`vars`; the verdict is published by ngit-ci to Nostr, so the report job just summarises). --------- Co-authored-by: felixfelix-bot <felixfelix-bot@users.noreply.github.com> * feat(wireless): wire the vendor-IE price producer the scan boundary staged for (#959) * feat(wireless): wire the vendor-IE price producer the scan boundary staged for PricePerStep/StepSize were declared across four data models (NetworkInfo, Gateway, DiscoveryEntry, KnownTollGate) and had no producer: scanner.go hard-zeroed them with a 'STAGED (this PR)' comment, gated behind the VendorIEDiscovery flag whose consumer did not exist. Wire it: - scanner.go: capture the raw IE blob from the scan's 'IE: <hex>' line and route network assembly through a new buildNetworkInfo(), which calls the existing ParseVendorIEsFromScanData() to fill IsTollGate / RawIEs and the pricing fields. Remove the staged comment. - vendor_element_manager.go: add pricing TLVs (0x03 price_per_step, 0x04 step_size, both 2-byte big-endian) to the encoder and parser, so the IE can carry a price hint. - types.go: TollGateAdvertisement gains PricePerStep/StepSize. Authority is unchanged and now documented in-code: the IE is an UNSIGNED hint via OUI identification that selects a candidate for the signed kind-10021 probe on :2121, which remains the authority for price. A zero pricing value means 'not advertised here', never 'free'. Pricing TLVs are dropped (truncated=true) rather than overflowing the 255-byte element or pushing out the pubkey, because identification outranks the price hint. Tests (observed RED before the change, GREEN after): - TestParseIwinfoOutput_PopulatesVendorIEFields - TestParseIwinfoOutput_NonTollGateIEIgnored - TestParseIwinfoOutput_TollGateIEWithoutPricingTLVs - TestParseIwinfoOutput_NoIEMeansNotTollGate - TestParseIwinfoOutput_MultipleNetworksAndIEs - TestVendorIE_RoundTripPricing (+4 subtests) - TestVendorIE_PricingDroppedRatherThanOverflow Full wireless_gateway_manager suite green, including the five pre-existing vendor-IE tests (no regressions). * fix(wireless): parse the vendor element from the format the router actually emits Hardware verification on tollgate-A04E (2026-10-16) falsified the first implementation's assumption. Measured: - `iwinfo <dev> scan` prints NO information elements at all (grep -c 'IE:' returned 0), so it can never yield IsTollGate via the vendor element. It does report hidden APs, as `ESSID: "unknown"` — a literal placeholder. - `iw dev <dev> scan` is the ONLY element source, and it prints elements DECODED into named fields (SSID:, RSN:, HT capabilities:, ...), not as a hex blob. A vendor element appears as `Vendor specific: <colon-hex>`. - iw strips the element ID (0xDD) and the length byte from that body. Feeding the body straight to ParseVendorIEsFromScanData returns 0 elements: measured as 36 good bytes in, 0 advertisements out. The body must be re-framed with its 0xDD + length prefix. Change: - ParseIwinfoOutput now accepts BOTH real formats: iwinfo `Cell NN - Address:` records and iw `BSS <mac>(on ...)` records, per-record, so a stream carrying either parses. - SSID parsing handles both keys; RSN supplies encryption for iw output (it has no `Encryption:` line); `freq:` supplies the channel. - The vendor element is re-framed before parsing. - Placeholder SSIDs ("", "(hidden)", "unknown") are refused admission, so a literal cannot become a selectable upstream. Tests rewritten against the measured format: TestParseIWScanOutput_{PopulatesVendorIEFields,NonTollGateVendorIgnored, TollGateWithoutPricingTLVs,NoVendorElementMeansNotTollGate, RealHardwareHiddenSSID,MultipleNetworksAndVendorElements}. Three failed against the raw-body bug and pass with the re-frame; the full wireless_gateway_manager suite is green with no regressions. * fix(wireless): read VendorIEDiscovery from UpstreamWifi; scan via iw when enabled Build fix: the flag lives on the UpstreamWifi sub-struct, not on Config. With that wired, the scan path can finally populate the fields the vendor element carries. Two facts measured on tollgate-A04E (2026-10-16) drive the design: 1. `iwinfo <dev> scan` prints NO information elements, so it can never identify a TollGate by vendor element. It is kept because it reports hidden APs (as ESSID "unknown") that iw can omit. 2. `iw dev <dev> scan` is the only element source. When vendor-IE discovery is enabled, scanRadio runs it as well and mergeScanResults folds the two views by BSSID, letting the element-derived fields (IsTollGate, RawIEs, price, step) come from iw while iwinfo still contributes entries iw lacks. iw is best-effort: a failure or empty result keeps the iwinfo view rather than failing the scan. * test(wireless): cover mergeScanResults, scanRadioWithIW and the flag wiring Coverage on the newly added code was incomplete: mergeScanResults and scanRadioWithIW were both at 0%, and parseColonHex at 75%. The merge is the join that lets element-derived fields come from iw while iwinfo-only entries (notably hidden APs) survive, so an untested merge was the largest risk in this change. Added: - mergeScanResults: enriches an existing BSSID from the second view; keeps entries only one view saw; fills gaps (signal/encryption); is the identity when the second view is empty. - scanRadioWithIW: a failed iw invocation returns nil rather than propagating an error, so the caller keeps the iwinfo view. - NewScanner/SetVendorIEDiscovery wiring. Also moves the on-router demo to src/cmd/tg-vendor-ie-demo, so it compiles against the repo's own module graph (the nested scanner module is reached via the existing replace directives) instead of a hand-rolled go.mod. Coverage on the new surface is now: mergeScanResults 100%, appendScanEntry 100%, NewScanner 100%, SetVendorIEDiscovery 100%, ParseIwinfoOutput 98.6%, buildNetworkInfo 92.9%, scanRadioWithIW 70%, parseColonHex 75%. * docs(wireless): record the cross-build target defect found on hardware The demo binary was built with GOOS=linux GOARCH=arm64 but the router rejected it with 'syntax error: unexpected end of file'. Its ELF header read 02 00 3e 00 (e_machine 0x3e = x86-64) instead of 02 00 b7 00 (0xb7 = aarch64). The sha256 matched the local file exactly, so the transfer was correct — the build target was wrong. Adding CGO_ENABLED=0 produced a proper statically linked aarch64 binary. The check that would have caught it in one line, now recorded for reuse: head -c 20 <binary> | od -An -tx1 | tail -1 # want '02 00 b7 00' for aarch64 Size and sha256 are NOT evidence of the right architecture; both matched while the binary was unrunnable on the target. * feat(demo): add --file mode and record the on-router demo evidence The demo could only run 'iw dev <dev> scan' live. Where no neighbour running this build exists (as on tollgate-A04E), that can only ever show the ABSENCE of a vendor element, never the positive identification path. Adding '--file <path>' lets the parser be run against a saved scan, so a synthetic element can be injected into REAL captured router output. Evidence from the live MT6000 (GL.iNet GL-MT6000, OpenWrt 24.10.2, aarch64_cortex-a53), 2026-10-16: A. live scan: iw dev phy0-ap0 scan: 28736 bytes, err=<nil> Vendor specific: lines present = 0 parsed networks = 12 identified TollGates = 0 (correct: no neighbour runs this build) B. real scan + one injected TollGate element: read /tmp/injected-scan.txt: 35168 bytes Vendor specific: lines present = 1 parsed networks = 16 TOLLGATE bssid=b2:36:4a:2a:03:32 ssid="StarGate" signal=0 price_per_step=7 step_size=1000 rawIE_bytes=38 identified TollGates = 1 -> the parser located the element inside genuine scan data and recovered the encoded price/step exactly. The demo binary was cross-built with CGO_ENABLED=0 and its ELF machine field verified as 0xb7 (aarch64) BEFORE transfer — the check whose absence produced the unrunnable x86-64 binary recorded in the previous commit. * fix(wireless): emit pricing before the pubkey so an oversized pubkey fails loudly Defect found by a cold cross-family review (deepseek-flash, PR #959, 2026-10-16). With the original ordering a pubkey could pass the fit check and land the body at 253-255 bytes; the pricing TLVs were then appended AFTER it, the 4-byte guard fired, and the pricing was silently dropped while the element still reported success. A router whose pubkey drifted large would appear to advertise no price — indistinguishable from a router that advertises none. Fix: emit pricing BEFORE the pubkey. An oversized pubkey now fails the existing loud error path ('vendor IE body too long') instead of silently losing the price. The length octet is also clamped explicitly with truncated=true, so a wrap can never produce a malformed element silently. Tests (observed RED against the original ordering, GREEN after): - TestEncode_PricingNotDroppedByPubkey (fails: 'pubkey 239 bytes cannot fit') - TestEncode_PricingSurvivesWithMaxFitPubkey (loud error for oversized pubkey) - TestEncode_LengthOctetNeverWraps (octet == body length) Encoder coverage 87.2%. Full wireless_gateway_manager suite green; go vet clean. MOUNTED BINARY VERIFIED: demo rebuilt for the router and its ELF machine field checked as 02 00 b7 00 (aarch64) before any transfer. Credit: this defect was surfaced by the cross-family lane, not by me. * refactor(wireless): rename the scan-s…
… (#1102) * feat(packaging): let the operator choose the admin password at install time (#827) A router whose root password was unset received a GENERATED credential, printed exactly once. Its only other copy went to /tmp/tollgate-setup.log -- tmpfs, and truncated at the start of every full-setup run -- so an install or upgrade erased the record. "Shown once" is only safe if there is a way not to need it. Two changes: - The install honours TOLLGATE_ADMIN_PASSWORD. uci-defaults run from the package manager with no controlling terminal, so the environment is where a choice can arrive; this is also the mechanism a fleet or CI provisioning script uses. The value is applied to root, never echoed to the output or the setup log, and replaces an existing credential when supplied. If it does not take, the gate fails closed and drops the admin listeners rather than leaving the board on the old credential. - A credential this package GENERATES is now marked provisional by /etc/tollgate/admin-credential-provisional (no secret in it -- the file is world-readable). The marker is (a) the signal the config UI reads to force a password change at first login and (b) the reason a later setup run re-states the recovery path instead of implying the operator chose it. A locked root account and an unreadable shadow stay exempt: neither is a state this package should decide to unlock. Tests extend tests/packaging/admin-board-requires-credential_test.sh (its own harness, per repo doctrine). RED observed first: 5 failures against the pre-change script with the 77 existing assertions passing. Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * fix(packaging): declare the CA bundle and state the manual-install trust chain (#828) * fix(packaging): declare the CA bundle and state the manual-install trust chain Installing the shipped package by hand on a GL-MT3000 (OpenWrt 25.12.5) failed at the first HTTPS fetch: wget AND apk update both died with "SSL verify error: unknown error". The image had no CA store, so no TLS connection could be verified. This module's job is money. It talks to Cashu mints and Lightning over TLS, the Go binary uses the system root pool (no InsecureSkipVerify, no bundled pool), so without a CA bundle every mint and Lightning call fails verification -- a gateway that cannot verify TLS cannot take a payment. The installer used to paper over this by fixing the box first; installing the package alone has to work, so ca-bundle is now a declared dependency rather than an assumption. The docs also gain the chain that makes an unsigned artifact safe to install by hand, because "install it yourself" is only honest if the verification is stated: the release's SHA256SUMS is signed (SSHSIG/ed25519, public half at .github/release-keys/release-signing.pub), so verify the signature FIRST, then check the artifact hash, then install. The artifact carries no apk-level signature, which is exactly why the manifest must be verified before the bytes are trusted. On a stock router ssh-keygen is absent (dropbear ships, not OpenSSH), so the manifest is verified on a trusted machine and the hash checked on the router. tests/packaging/apk-install-resolution_test.sh gains source-level assertions for both declarations, run BEFORE its docker gate so a machine without docker still checks them, and its header's honesty list now says what it does and does not prove about trust. RED observed first (both source files reverted, test kept): the CA assertion and two of the three documented-chain assertions fail. GREEN after: all four pass and the docker closure lane still resolves (12.5 MiB, 156 packages). * docs(packaging): point manifest verification at allowed_signers, not the bare key The README named .github/release-keys/release-signing.pub as the key to use, while the command it printed passed -f allowed_signers. Those are different files in different formats: release-signing.pub is a bare ed25519 public key, allowed_signers is the same key prefixed with the principal the -I identity is matched against. Following the prose sent the reader to a file the verifier rejects. Name the file the verifier consumes and fetch it explicitly. The lane-pinning needle added here is a PRESENCE pin only: allowed_signers already appeared in the printed command, so it does not go RED against the pre-fix README. This fixes documentation accuracy, not behaviour. * fix(packaging): declare the CA bundle on the .ipk lanes too The CA-bundle dependency was declared only in the SDK recipe (packaging/Makefile). The two .ipk build lanes -- packaging/local-build-ipk.sh and .github/workflows/build-package.yml -- still stamped `Depends: libc, nodogsplash, jq`, and scripts/ngit-gen-shards.py propagated the same line into every generated .ngit shard, so the OpenWrt <=24.10 opkg lane installed a module with no CA store and the same class of TLS failure the apk lane hit was still reachable there. Declare ca-bundle in the recipe, the GitHub lane and the shard generator, and regenerate the shards (scripts/ngit-gen-shards.py --check is clean). The sibling contract test now expects the exact new Depends value rather than a prefix of it, so a future drop of the bundle is a failure and not a still-matching prefix. This is the dependency half of the same defect class: it fixes an install on a box that can still reach the feed. It does not, and cannot, bootstrap a box with no trust anchors -- that path is stated in the README. Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * docs(packaging): state the honest offline bootstrap and pin the key file The README claimed the +ca-bundle dependency makes a hand install work. It does not close the failure the change reports: on the box the defect was found on, apk update itself died with an SSL verify error, and apk must fetch ca-bundle over the same TLS it cannot verify. The dependency is correct for a box that CAN reach the feed; it cannot repair one with no trust anchors. Say so. The dependency fixes a feed-reachable box (the closure is resolved and ca-bundle comes with it). For a box with no trust anchors the README now gives the offline path instead: bring the package files over by other means (scp) and install them locally in one apk call under a single explicit trust override, with the opkg equivalent for the <=24.10 lane and the clock caveat. Verified in a container (openwrt/rootfs 25.12, CA store removed): a local `apk add --allow-untrusted ./pkg.apk` completes even while every HTTPS fetch fails with an SSL verify error, because the local file install does not need the fetch to succeed. Two accuracy fixes in the same section: pin the fetched key file to a commit SHA instead of the moving master branch, and name the real fetchers on a stock router (wget or uclient-fetch, not curl) -- curl is as absent as ssh-keygen, so the previous note listed only half of what is missing. Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * docs(changelog): record installer-free-trust as added, without the overclaim The fragment narrated added behaviour and docs, so .changed was the wrong suffix; rename it to .added (the repo convention, see vm-campaign.added.md and 679-changelog-fragments.added.md) and reword it to match what the change now does: ca-bundle is a declared dependency on every build lane, which fixes an install on a box that can reach the feed, and the README states the offline path for a box that cannot -- rather than implying the dependency alone bootstraps an anchor-less box. Also drop the release-signing.pub reference: the verifier consumes the allowed-signers file, not the bare key beside it. That mismatch was fixed once already in 084b1179 and the fragment reintroduced it. Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * test(packaging): assert the CA dependency from build metadata, not grepped text apk-install-resolution_test.sh "proved" the CA-bundle declaration and the documented trust chain by grepping README.md and packaging/Makefile for literal strings. That was tautological, and the three failure modes are reproducible against the old test: - a README that tells the reader to never use the override still passes (the literal string is still present); - a dependency living only inside a `# ... +ca-bundle` comment still passes; - a formatting-only Makefile edit (extra spaces) FAILS, because the regex pins the source text rather than the declaration. Replace both greps with assertions over build metadata: (1) apk/SDK lane: expand packaging/Makefile with `make -pn` and require the bundle in the DEPENDS of the package definition make reads. Comment lines are dropped before matching, so a dependency that exists only in a comment cannot satisfy it, and whitespace no longer matters; (2) opkg/.ipk lane: build a real .ipk from the recipe's DEPENDS with packaging/build-ipk.sh and assert the `Depends:` field of the control file opkg consumes -- the byte-level artefact, which raw text cannot fake. Both run before the docker gate and both fail closed. The header now states exactly what each check proves and what it does not (the SDK expansion proves the recipe declares it, not that a build emitted it; the artifact check is the load-bearing one). RED observed first, then GREEN: - dependency moved into a comment in packaging/Makefile -> check (1) FAIL, exit 1 (the old test passed this case); - ca-bundle removed from packaging/local-build-ipk.sh -> check (2) FAIL, exit 1; - extra whitespace only in packaging/Makefile -> both checks PASS (the old test failed this case); - old test vs a README that says "do NOT ever use this override" -> old test PASS, exit 0 (the tautology, reproduced); - all 12 tests/packaging suites pass with the new test in place. Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * docs(packaging): verify the trust anchor before installing it locally * docs(packaging): qualify the feed DEPENDS citation (ca-bundle lands with packages#57) * docs(changelog): lead with the declared dependency, keep the GL-MT3000 as evidence * docs(readme): fail-closed on every staged package file, not just the first --------- Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> Co-authored-by: Felix <felix@opentollgate.org> Co-authored-by: Felix <felix@freedomtechfeed.org> * fix(packaging): run the fail-closed admin gate LAST so the credential exists first (#838) * fix(packaging): run the fail-closed admin gate LAST so the credential exists first The admin board's setup script (staged from the pinned portal tree) is fail closed: while root's /etc/shadow hash is empty it DELETES uhttpd.admin's listeners, on the principle that the board must be unreachable rather than merely unhelpful. 99-tollgate-setup is what CREATES that credential. The recipes ran the uci-defaults in numeric order (90, 92, 99), so on a fresh hand-install the gate ran BEFORE the credential existed and killed the admin board with nothing to restore it. Stage the portal's 92-tollgate-admin-setup as 999-tollgate-admin-setup instead, so it sorts after 99-tollgate-setup in the boot glob, and run 90, 99, 999 on both install recipes (the packaging/Makefile postinst loop and packaging/postinst). The numeric prefix IS the boot order, because /etc/init.d/boot applies /etc/uci-defaults/* in one collated glob, so an install and the next boot now agree on the last writer. Also resolve the gate's operator-facing messages from the same entry_ui mapping the listener drop uses (:8080/:443 in board mode, :8090/:8443 in luci mode) instead of a hardcoded :8090, so the refusal and the board's own script name the same URL for one board. tests/packaging/uci-defaults-run-order_test.sh pins the gate-last contract - the credential runs before the gate, the gate runs last, and the gate's installed filename sorts after the credential script's - judged by one predicate, with the pre-change numeric order and a recipe-only reorder as negative controls and the gate-last order as the positive control. * test(packaging): keep the admin-board suite off the build host The suite exercised the shipped driver in a sandbox but left three ABSOLUTE paths pointed at the real filesystem: * /etc/tollgate/entry-ui-mapping -- READ. A marker left on the build host leaks in and flips entry_ui, and with it the board's pair. Measured 2026-10-09: a stray marker containing `board` turned this suite's expected ':8090/:8443' refusal into ':8080/:443' and failed it on an otherwise green tree (the only net-new failure in this branch). * /etc/tollgate/config.json -- READ, and not overridable at all. * /etc/tollgate/admin-credential-provisional -- WRITTEN. An un-sandboxed run deposits a generated credential in the build host's /etc/tollgate. Pin all three inside $TMP, and make ENTRY_UI_CONFIG overridable via TOLLGATE_ENTRY_UI_CONFIG -- the same `${VAR:-default}` seam the adjacent ENTRY_UI_MARKER and ENTRY_UI_JQ_BIN already use, so the harness needs no new mechanism. Verified, packaging tier, same loop on both trees: origin/main (22f111ab): 267 passed / 33 failed this branch : 277 passed / 9 failed The 9 remaining failures are in configui-8090-single-owner_test.sh and are BYTE-IDENTICAL to main's (diffed the failing assertion text) -- pre-existing double-ownership of :8090, untouched here. --------- Co-authored-by: Felix <felix@opentollgate.org> Co-authored-by: Felix <felix@freedomtechfeed.org> * docs(agents): firewall/naming rules of engagement, verification-venue map, changelog fragment convention (#800) * docs(agents): firewall/naming rules of engagement, verification-venue map, changelog fragment convention Three additions distilled from the 2026-10-09 stop-ship reviews and bench sessions (#754/#755/#756/#757, PR #782's first design): - Firewall, nftables and topology-naming rules of engagement: accept is not final across base chains (a green render-level contract suite hid a dead design in #782), no runtime nft inserts, defines-not-literals including generated fragments (34- generator is the open instance; #601's planned 33- is pre-committed), same-priority cross-family ordering fragility, fw4-reload NDS-state-wipe disclosure duty, the three-namespaces 'lan' hazard + netifd wireless regen, and the Go-side interface-literal grep list. - Where each class of verification lives: contract suites (mutation-check duty; a cited gate must test the cited thing), per-era bench lanes with an image-doctor preflight, PRTA as the home for topology/journey scenarios, cloud-lab crash lanes, the physical tier. - Changelog requirements rewritten to the changelog.d fragment convention, fixing the AGENTS.md / changelog.d-README split-brain that mis-led three PRs in one week. * docs(changelog): fragment for the AGENTS.md knowledge rules (#800) --------- Co-authored-by: Amperstrand <dev@amperstrand.no> Co-authored-by: Amperstrand <amperstrand@users.noreply.github.com> * test(merchant): late-receive outcome selection raced the owed-grant ERROR line — pick the COMPLETED record by name (#681 red herring) (#718) * test(merchant): duplicate-guard harness needs an ndsctl on PATH — install the renewal fake (#822) (#835) * test(merchant): FiredOnce pinned the opposite of the wired contract — assert fire-exactly-once deterministically (#732) (#836) * test(contract): SSID matcher fence — pinned acceptance table for hasTollGateSSID (#618/#706 classes) (#839) The reader twin of check-ssid-format.sh: what the WRITER may emit is pinned there, what the MATCHER must accept is pinned here. One fixture table (tests/contract/ssid-naming-fixtures.txt) drives both the Go unit test and tests/contract/check-ssid-naming.sh (structural: table covers every required class and the Go pin is wired; behavioural: the real go test runs over the table). Classes: both brands bare (#618 — the matcher was brand-blind and a whitelabel upstream was invisible), case- insensitive (lowercase installer-era SSIDs still in the fleet), single-'!'-decorated with double-'!' rejected (#706). Lands #706's reader-side tolerance (trim at most one '!') so the table is green on merge; #706 then only needs its writer/installer half. The whitelabel fixtures carry the brand literal in the table's file — the SSID contract's allowlisted home in the rebrand gutter (same exemption as check-ssid-format.sh); Go source keeps assembling it from non-contiguous bytes. Negative control: a planted contradictory fixture line turns the fence red (behavioural layer), removed again. Refs #767 Co-authored-by: Amperstrand <dev@amperstrand.no> Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * test(merchant): closeGateCleanup clears an abandoned close streak — the streak outlived the test that spent it (#841) (#842) * fix(wallet,merchant): correlate NUT-07 answers by value; fsync journal directories after rename (#834) (#876) * fix(wallet,merchant): correlate NUT-07 answers by value; fsync journal directories after rename (#834) Two audit-hardening halves of #834: - CheckTokenSpent counted answered states but never correlated each response Y back to a requested one. It now correlates by value: an answer about a Y we did not ask about, or a duplicate answer padding a short reply, is an error instead of a silent verdict — the #502 pending-intent reconciliation (abandoned vs owed) decides on this evidence, and NUT-07 requires states to match the request. - the three money journals (receive intents, owed grants, quote store) fsynced the temp file but not the directory after rename; on ext4-class filesystems the rename itself is only durable after the directory entry is synced, so a power cut could drop the newest journal write whose contents were already fsynced. syncParentDir closes the gap after every rename, surfacing a failed dir fsync as a save error (the record must be treated as not-yet-durable, exactly like a failed temp Sync). * changelog: fragment for #840 (NUT-07 value correlation + journal dir fsyncs) * test(tollwallet): pin the NUT-07 shortfall refusal — direct coverage for the answered-N-of-M branch --------- Co-authored-by: Amperstrand <dev@amperstrand.no> Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * fix(deps): the gonuts pin is one version, everywhere — manifest truth, glob fence, mechanical bump (#791) (#798) The wallet fork's pin drifted three ways in one release week: a hand bump moved four carriers and left a fifth (scripts/token-recovery) three minors behind; two open PRs pinned the same lines to a tag and a pseudo-version; a newer fork tag carrying an open-P2 fix went unnoticed. - packaging/build-inputs.json names the pin (.gonuts.version) as the single source of truth, beside the go and portal pins. - tests/contract/check-gonuts-pin.py discovers carriers by glob (both go.mod require shapes — the single-line form hid the token-recovery stale pin from the checker's first draft) and refuses any disagreement, offline, in pre-commit and both CI twins; --latest (release-check only) refuses a release while the fork has a newer stable tag. - tests/contract/gonuts-pin_test.sh: 7 planted-drift verdicts. - scripts/bump-gonuts.sh rewrites manifest and every carrier together. - scripts/token-recovery raised v0.10.0 -> v0.13.0 (the fence's first catch; builds and vets clean against the new pin). - AGENTS.md states the rule and the check, never a carrier count. Co-authored-by: pr-train-lane <lane@opencode> * fix(packaging): allow pre-auth DNS on UDP, not only TCP — users_to_router udp/53 (#749) (#769) The nodogsplash pre-auth allow list carried tcp/53 (the feed default) but not udp/53, and stub resolvers query UDP first: the ndsRTR chain dropped the query, the client burned a ~5s timeout per attempt before any TCP fallback, and hostname resolution from a captive client was dead in practice — while the portal page itself is usually reached by name (local-lab campaign behind PRTA PR #101). assert_nodogsplash_allow_entries now asserts BOTH protocols, with a new protocol-aware guard (uci_list_has_entry): uci_list_has_port matches a port on either protocol, so guarding udp/53 with it would see the feed's tcp/53 entry and skip the add. tcp/53 is asserted alongside (not left to the feed default) because the writer's contract is that a stale or absent list is repaired on every setup path. tests/uci-defaults-dns-preauth_test.sh pins the matrix against the shipped writer via the fake-uci harness: feed-default list gains udp/53 without duplicating tcp/53, a stale list is repaired on both protocols, the guard is proven protocol-aware (udp present + tcp missing still adds tcp), reruns add nothing, and uci's quoted single-line rendering is handled. The same-version allowlist and convergence suites carry the DNS entries through their full-script runs; CI runs the new suite on every push. Fixes #749 * chore(packaging): pin the portal at the merged network-settings revision (#766) The portal pin moves to 6c25834c (OpenTollGate/tollgate-captive-portal-site#66, merged) — the board half of the operator settings whose module half landed in #604: the Settings cards for the private network's credentials and the administration scope, and the WiFi page's private-radio edit routed through `tollgate config_set` with the section-name contract pinned (both `wireless.private_radio0/1` names and the structural AP-on-`private` signal, read in the netifd `config`-nested shape and the flat fallback) and the half-applied save named rather than swallowed. The gate trail is on the PR: rounds 1-3 CHANGES-REQUESTED, round 4 flagged the structural net reading the wrong shape, round 5 delta verification APPROVED. Evidence: 23/23 Playwright specs across the three admin suites, 4/4 unit tests, tsc clean, packaging guards and the anti-leak gate green. The .secrets.baseline entry for the pin line moves with it, per the baseline's documented purpose of recording reviewed pins: a pin whose hash is not recorded is (correctly) a new alert. As with #714, the pin is the atomicity boundary: the module's rc1 changelog entry already says these settings are settable "on the board" — only a release built after this commit actually carries the board half. Co-authored-by: Amperstrand <amperstrand@localhost> * fix(cloud-lab): Dockerfile.client's GO_VERSION must be a global ARG — the client image cannot build from scratch (#844) * fix(cloud-lab): Dockerfile.client's GO_VERSION must be a GLOBAL arg — the client image cannot build from scratch Dockerfile spec: only args declared before the FIRST FROM are visible to FROM lines. Dockerfile.client declared ARG GO_VERSION=1.26.8 inside the rust-builder stage (after FROM rust:1.88-bookworm) and then used it in the go-builder stage's FROM — where it resolves empty, so every from-scratch build of the client/killer image fails: failed to solve: failed to parse stage name "golang:-bookworm": invalid reference format Introduced by #687's toolchain-parity sweep, which added the ARG to both cloud-lab Dockerfiles — correctly placed in Dockerfile.tollgate (before its first FROM), mid-file in Dockerfile.client. Nothing caught it because the org CI that builds these images has been dark and local lanes ran on cached images. Move the ARG above the first FROM. Verified on the rig (ai-legion, docker 29.1.3/compose 2.40): with this exact change the killer image builds and the #497 crash-injection lane runs to completion on #793's head — pending swap recovered, post-recovery payment 200 (transcript: /root/src/rel-793/transcript-stock.txt on ai-legion). * docs(changelog): fragment for the cloud-lab client global-ARG fix * chore: re-sync the PR head after auto-merge churn (no content change) --------- Co-authored-by: Amperstrand <amperstrand@users.noreply.github.com> Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * fix(merchant): receiveTimeout as per-merchant seam — the package var raced leaked PurchaseSession readers (#821) (#837) * fix(setup): never print generated credentials; add TOLLGATE_PRIVATE_WIFI_PASSWORD (#850) * fix(setup): never print the generated admin/WiFi credentials; add TOLLGATE_PRIVATE_WIFI_PASSWORD * docs(changelog): fragment for the no-print credential change * fix(wireless_gateway_manager): upstream connect refreshes credentials and clears competing STAs (#817) (#848) * fix(wireless_gateway_manager): upstream connect refreshes credentials and clears competing STAs (#817) Two defects chained into the desk-run failure: Reuse: FindOrCreateSTAForSSID returned a disabled STA section for the SSID without writing the new passphrase — a rotated password was dropped, the join failed WRONG_KEY, and the flow reported a DHCP wait. The reuse path now rewrites encryption, key (deleted for open networks) and network=wwan before returning. Exclusivity: mt76 exposes ONE STA netdev per radio, so any other enabled STA section on the target radio keeps the netdev and its (possibly stale or foreign — the desk run's tollgate_uplink) key is what wpa_supplicant joins with while the fresh candidate never gets an interface. SwitchUpstream now disables every enabled STA on the candidate radio, not just the section GetActiveSTA named; the auto-switcher in upstream_manager inherits the same guarantee. Tests drive the real Connector through an in-memory uci fake (the mock-level tests asserted canned returns and never touched this logic); all three new tests are planted-drift-verified to fail on the pre-fix code. * fix(wireless_gateway_manager): address cross-family review — log the stale-key delete, note the revert side effect Two non-blocking findings from #848's review: the open-network transition's key delete now logs failures at debug (the delete is the safeguard itself; an error was silent), and SwitchUpstream's comment states that a reverted switch deliberately leaves competing STAs disabled — the stale key must not regain the netdev — pinned by the revert test. * fix(wireless_gateway_manager): map bare 'WPA PSK' scans to psk-mixed iwinfo reports 'WPA PSK (CCMP)' both for genuine WPA1 networks and for mixed/WPA2-preferring APs whose beacon leads with the WPA1 IE — measured on the lab bench: such an AP rejects a psk section's WPA1 auth and only completes WPA2, so the connect flow wrote a section that could never handshake and reported a DHCP wait (#817's second root cause, same operator-visible symptom as the garbage-key defect). psk-mixed negotiates either way; regression test pins the measured AP shapes. * test(wireless_gateway_manager): pin the psk-mixed mapping in the legacy DetectEncryption table The pre-existing table asserted the old WPA1 mapping for 'WPA PSK (TKIP)'; 0e3da8f deliberately maps that shape to psk-mixed (iwinfo prints the same string for genuine WPA1 and for WPA2-preferring APs whose beacon leads with the WPA1 IE — measured on the bench; psk-mixed negotiates either). Caught by the full go-battery, which the -run filter had skipped via a non-matching test name. --------- Co-authored-by: Amperstrand <dev@amperstrand.no> * fix(setup): never generate a credential the operator cannot see (M1) (#896) * fix(setup): never generate a credential the operator cannot see M1: when root has no password and TOLLGATE_ADMIN_PASSWORD is unset, the package now generates NOTHING and refuses to serve the admin board, instead of creating a provisional credential. A generated-but-withheld password is exactly how routers got locked out (the installer deferred one credential while this package generated another; the operator held neither). The admin password must be provided: as TOLLGATE_ADMIN_PASSWORD at install time, or with `passwd root`, then the gate is re-run. The generate_admin_password/mark_admin_credential_provisional helpers are removed; a provisional marker carried over from an older install is still honoured so an upgraded router keeps telling the operator the credential was generated. * changelog: fragment for #884; fix #850 fragment format The #850 fragment started with a heading instead of a bullet, so changelog-assemble.py check failed on main. Reformatted it as a bullet. --------- Co-authored-by: Your Name <you@example.com> * setup: read the operator credential from a root-only file too (#897) apk-tools 3 (OpenWrt >= 25.12) does NOT forward the caller's environment to package scriptlets, so `TOLLGATE_ADMIN_PASSWORD='…' apk add` never reaches 99-tollgate-setup there: the module saw the env empty and refused the admin board (fail closed, no lockout, but no board). Proven on a GL-MT3000 running vanilla 25.12.5 / apk-tools 3.0.5. operator_chosen_admin_password now falls back to a root-only file (/etc/tollgate/admin-password); the private-WiFi passphrase likewise falls back to /etc/tollgate/wifi-password. Both are consumed (removed) once applied. The env still wins when present, so opkg (where the env IS forwarded) is unchanged. tests/packaging/admin-board-requires-credential_test.sh: +2 cases (staged file applies when the env is empty; env wins over the file). 80 passed, 0 failed. Co-authored-by: Your Name <you@example.com> * setup: never SET a credential — admin or private-WiFi (#884 follow-up) (#902) The package already stopped GENERATING an admin credential (#884), but it still SET one from the install environment (TOLLGATE_ADMIN_PASSWORD / TOLLGATE_PRIVATE_WIFI_PASSWORD) or a staged root-only file (/etc/tollgate/admin-password, /etc/tollgate/wifi-password). That is the same hazard one step over: an operator who pasted the run-book's EXAMPLE value verbatim ended up with root set to a string they never chose, with no link to it. Remove every credential write. 99-tollgate-setup: - drop TOLLGATE_ADMIN_PASSWORD / TOLLGATE_PRIVATE_WIFI_PASSWORD, ADMIN_PASSWORD_FILE / PRIVATE_WIFI_PASSWORD_FILE, operator_chosen_admin_password(), set_admin_password(), clear_admin_credential_provisional() and the generated private-WiFi PSK; - enforce_admin_credential now only ASCERTAINS: set/locked serve, empty/unknown refuse and drop the board, telling the operator to set one with `passwd root` (or LuCI) and re-run; - setup_private_network: preserve an existing passphrase, otherwise leave the private AP DISABLED (wired br-private is unaffected) until one is set; - keep reading the legacy admin-credential-provisional marker on upgrade. CLI: - `tollgate network private set-password` and the config applier now also set disabled=0 on the radio they write a passphrase onto, so setting one actually brings the (disabled-by-default) AP up. Tests: - admin-board-requires-credential: rewritten around the new invariant; ADD the regression that env AND file present leaves root byte-identical, passwd never invoked, the staged file unconsumed, and the board refused; - new tests/uci-defaults-private-credential_test.sh: no passphrase => no key, AP disabled; env/file ignored; existing key preserved+enabled; - new src/cli/network_setpassword_test.go: set-password and the applier enable. Docs: README + unreleased changelog fragments updated to match. Runs green in the module test tiers (only the pre-existing entry-ui-mapping_test.sh failures remain, identical to main). Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * chore: VERSION v0.6.0-rc2 (#903) Stamp the release candidate for the no-generated-credentials work. The feed's PKG_SOURCE_TAG is read from this file, so the built binary reports v0.6.0-rc2 to match the package version 0.6.0_rc2. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * rebase onto main (post-rc2 + #798 fence + credential series): builds: enforce the two-path doctrine — surface contract test, one-pass matrix split, rails (#713) Co-authored-by: Amperstrand <dev@amperstrand.no> * packaging: refuse to install when root has no password (preinst) + VERSION v0.6.0-rc3 (#907) The :8090 admin board's login is checked against root's /etc/shadow hash, and rpcd's session.login accepts ANY password while that hash is empty. The package never sets a credential, so a password-less install would leave an unauthenticatable board. Rather than install and then withhold the board, refuse BEFORE unpacking: the preinst exits non-zero on an empty root hash, so nothing is half-installed and the operator is told to `passwd root` and retry. - preinst (was a deliberate no-op): dependency-free (awk, no jq — the past jq-preinst-aborted-upgrades lesson), allows a locked root, bypasses on IPKG_INSTROOT (offline/image builds) and TOLLGATE_ALLOW_EMPTY_ROOT=1 (CI hatch). - new tests/packaging/preinst-refuses-empty-root_test.sh (9 checks). - VERSION -> v0.6.0-rc3. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * setup: default to a fixed TollGate credential instead of refusing (#913) * setup: default to a fixed TollGate credential instead of refusing The admin board's login is checked against root's /etc/shadow hash, and rpcd's session.login accepts ANY password while it is empty. rc3 refused to install in that state, but a failing pre-install is NON-FATAL under apk-tools 3 (the .apk landed anyway) and requiring `passwd root` before install burdened every operator. Replace the refusal with a FIXED default: 99-tollgate-setup sets root to TOLLGATE_DEFAULT_CREDENTIAL ("TollGate") when none exists, sets the private (management) WiFi key to the same value, marks the admin credential PROVISIONAL, and serves the board. The admin/config UI forces a change at first login (shipped separately, post-release). No install-time credential is accepted (TOLLGATE_ADMIN_PASSWORD / staged file stay ignored); an operator's own credential is preserved; the write still fails CLOSED if it cannot take. - preinst refusal -> no-op (the two lanes agree now). - new tests/packaging/admin-board-defaults-to-tollgate_test.sh (59 checks); preinst-refuses-empty-root -> preinst-never-refuses; the private-credential and configui-8090 suites updated to the default contract. - VERSION -> v0.6.0-rc4. * setup: mark the defaulted private-WiFi key provisional too setup_private_network writes /etc/tollgate/private-wifi-credential-provisional when it installs the fixed default passphrase (rather than one the operator chose), so the post-release admin/config UI can force a Wi-Fi passphrase change at first login alongside the admin one. The test asserts it is written only when the default is applied. --------- Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * docs(packaging): record the decommission plan for the module's own package build (#914) Freeze the intent, not yet the change: FreedomTechFeed/packages/net/tollgate-wrt/Makefile is the single canonical shipping package; this repo's packaging/Makefile is test/reproducibility only. packaging/README.md lists exactly what the feed consumes (packaging/files/) and the mechanical removal (Package/Build targets, build-ipk/local-build-ipk/build-env helpers, the build-package.yml publish jobs) to execute after the v0.6.0-rc4 release. Draft PR; merged post-release. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * test(harness): pin the env seams the setup script now resolves (release-check green) (#915) Three uci-defaults suites were red at release-check (they are NOT in the per-PR test.yml lane, so #913 merged with them red): - entry-ui-mapping (9): the suite's sed predated the TOLLGATE_ENTRY_UI_CONFIG env override, so the copied 99 read the HOST's live /etc/tollgate/config.json (the harness rot #899 describes). - admin-tls-identity (6) + trusted-entry-80 (1): the new private-WiFi provisional marker defaults to an absolute path the suites did not redirect. Harness-only: pin TOLLGATE_ENTRY_UI_CONFIG + ADMIN/PRIVATE_WIFI_PROVISIONAL_MARKER into each sandbox. NO change to 99-tollgate-setup (the risky half of #899). Sweep: all tests/packaging/* + tests/uci-defaults-* green. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * packaging: re-check the private subnet once the uplink is up (#926) setup_private_network derives network.private (lan/24 ±1) at uci-defaults time, before a fresh DHCP WAN necessarily has a lease, so an upstream that lands on the same /24 as the derived private network — the observed 192.168.2.1 against a 192.168.2.0/24 upstream — is missed. The router then answers for the upstream gateway's own address and DNS breaks on the deployed router. Add recheck_private_subnet (no-op unless the private /24 now overlaps a known upstream) and call it from the postinst AFTER `/etc/init.d/network restart`, reloading the network only when the private subnet had to move. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * fix(tollwallet): bound each mint registration so a slow mint cannot stall boot (#940) * fix(tollwallet): bound each mint registration so a slow mint cannot stall boot The wallet already registers only the mints the startup probe found reachable, but gonuts' AddMint does its own network I/O with NO http.Client timeout, so a mint that accepts the connection and never answers can stall the whole wallet construction — the module's own notes measure 113-347 s for 7 mints. That outlasts the installer's health window and made a healthy install report 'the merchant is degraded'. registerMint now gates AddMint behind a bounded GET /v1/keysets with OUR OWN client (8 s). A mint that does not answer is left UNREGISTERED rather than blocking boot: ensureMintRegistered retries it on first use and the reachable-set callbacks admit it when it recovers. Registration stays sequential, so gonuts is never called concurrently. Test: TestMintServesKeysets (serves keysets -> accepted; empty -> rejected; outruns the bound -> rejected). tollwallet + merchant modules green. * review: reword the bound's guarantee, state the residual, add a defer->recover test Addresses the independent review on the fork PR: - The comment no longer claims AddMint 'cannot hang'. The probe and AddMint are separate exchanges, so the bound makes a hang UNLIKELY rather than impossible; the real closure (a goroutine-with-timeout around AddMint, or registration off the boot path) is noted as a follow-up. - States the residual worst case explicitly: one registerMintKeysetTimeout per reachable mint, sequentially — 7 mints x 8 s = 56 s at boot — plus the new first-use latency for a deferred mint (a bounded <=8 s inline probe on the first payment routed to it; latency, not correctness). - Adds TestRegisterMintDefersThenRecovers: a mint DOWN at boot is left unregistered, and ensureMintRegistered (the path Receive takes) registers it once it recovers — pinning the integration claim through the real gonuts wallet, not just the probe unit. --------- Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * docs(operator-guide): fix the entry_ui=luci TLS-port attribution (#939) The SSL-command scope paragraph paired LuCI's :8443 with entry_ui=luci, inverted: under entry_ui=luci LuCI (uhttpd.main) owns the entry pair TLS port :443 and the board owns :8443. Both ports named in the old sentence belong to the entry_ui=board mapping. Ground truth: packaging/files/etc/uci-defaults/99-tollgate-setup port functions and docs/access-urls.md's mapping table. Found by the PR #744 review; one-line fix-forward. * docs: defer loopback rate-limit vs startup-gate follow-up (post-release) (#953) Record the root cause behind the 2026-10-16 'Setup failed' incident: the installer's own 3s poll exceeds RateLimitMiddleware's 10 req/min for 127.0.0.1, and the limiter runs before requireStarted, so the probe can get 429 {'error':'rate limit exceeded'} instead of the readable 503 {'code':'starting'} refusal. The installer side is worked around in rc33. The root-cause fix (exempt loopback from the limiter) is DEFERRED until after the current release — no new module RC in this line. Co-authored-by: c03rad0r <c03rad0r@users.noreply.github.com> * fix(upstream): select and blacklist upstreams by BSSID, not SSID (#910) * fix(upstream): select and blacklist upstreams by BSSID, not SSID One SSID can be served by several BSSIDs across different upstream networks — some routed to the internet, some captive or isolated. The manager keyed its blacklist by SSID and never pinned the scanned BSSID on the candidate, so a router that settled on a BSSID with no internet blacklisted the whole SSID, including any working sibling, and could never recover. The ICMP-only connectivity check could not tell "gateway but no internet" from "ok" either. - Blacklist by BSSID (falling back to the SSID only when it is unknown), skip candidates by BSSID, and blacklist the exact failed BSSID on an emergency switch. A sibling BSSID of the same SSID stays eligible. - Thread the candidate BSSID through SwitchUpstream and pin wireless.<iface>.bssid, clearing a stale pin when the candidate carries none. Manual CLI connect pins the strongest scanned BSSID for the SSID. - Replace the ICMP-only probe with a layered one — default route, DNS resolution, HTTP 204 captive-portal check — returning a reason (no-route/no-dns/captive/no-http). Used by the startup check, the main loop and post-switch verification. Tests: two-BSSID/one-SSID blacklist isolation, BSSID pin set/clear, and probe classification. make go-battery green across all 16 modules. * changelog: fragment for #910 (BSSID-aware upstream selection) --------- Co-authored-by: c03rad0r <c03rad0r@users.noreply.github.com> * ci(router-test): TBMG Trust Pipeline + bench lanes (dual twin) (#898) * ci(router-test): TBMG Trust Pipeline on the self-hosted runner + verdict comment Router-test is the TBMG Trust Pipeline entry point. Two changes: * Run the gateway dispatch on the self-hosted runner (role 86-tbmg-runner on hermes-nvme) over the private fips mesh with its dedicated per-machine key — no per-repo secrets and no metered LAN traffic. * Add a report job that posts ONE idempotent verdict comment per PR (marker <!-- tbmg-trust -->), so every PR shows whether it is ready to merge. Fork PRs (which must never run on a self-hosted runner) are reported as "not tested" instead of silently skipped. The security contract is preserved: PR -> isolated lab; bench only on main or an explicit dispatch behind bench-hardware. * docs(changelog): fragment for the TBMG Trust Pipeline (#898) * ci(router-test): dispatch the bench lanes on the gateway Add a gated `bench` job (non-PR, bench-hardware) that SSHes to the elected router-bench-gateway over fips and runs scripts/bench/bench-lanes.sh — the lane orchestrator (clean flash -> install -> smoke -> radios OFF, JSON summary). * ci(ngit): mirror the router-test trust pipeline into the ngit twin Land the trust pipeline in the ngit execution twin too (dual setup): same plan/router-test/bench jobs, ngit-safe (no GitHub `environment`/`vars`; the verdict is published by ngit-ci to Nostr, so the report job just summarises). --------- Co-authored-by: felixfelix-bot <felixfelix-bot@users.noreply.github.com> * feat(wireless): wire the vendor-IE price producer the scan boundary staged for (#959) * feat(wireless): wire the vendor-IE price producer the scan boundary staged for PricePerStep/StepSize were declared across four data models (NetworkInfo, Gateway, DiscoveryEntry, KnownTollGate) and had no producer: scanner.go hard-zeroed them with a 'STAGED (this PR)' comment, gated behind the VendorIEDiscovery flag whose consumer did not exist. Wire it: - scanner.go: capture the raw IE blob from the scan's 'IE: <hex>' line and route network assembly through a new buildNetworkInfo(), which calls the existing ParseVendorIEsFromScanData() to fill IsTollGate / RawIEs and the pricing fields. Remove the staged comment. - vendor_element_manager.go: add pricing TLVs (0x03 price_per_step, 0x04 step_size, both 2-byte big-endian) to the encoder and parser, so the IE can carry a price hint. - types.go: TollGateAdvertisement gains PricePerStep/StepSize. Authority is unchanged and now documented in-code: the IE is an UNSIGNED hint via OUI identification that selects a candidate for the signed kind-10021 probe on :2121, which remains the authority for price. A zero pricing value means 'not advertised here', never 'free'. Pricing TLVs are dropped (truncated=true) rather than overflowing the 255-byte element or pushing out the pubkey, because identification outranks the price hint. Tests (observed RED before the change, GREEN after): - TestParseIwinfoOutput_PopulatesVendorIEFields - TestParseIwinfoOutput_NonTollGateIEIgnored - TestParseIwinfoOutput_TollGateIEWithoutPricingTLVs - TestParseIwinfoOutput_NoIEMeansNotTollGate - TestParseIwinfoOutput_MultipleNetworksAndIEs - TestVendorIE_RoundTripPricing (+4 subtests) - TestVendorIE_PricingDroppedRatherThanOverflow Full wireless_gateway_manager suite green, including the five pre-existing vendor-IE tests (no regressions). * fix(wireless): parse the vendor element from the format the router actually emits Hardware verification on tollgate-A04E (2026-10-16) falsified the first implementation's assumption. Measured: - `iwinfo <dev> scan` prints NO information elements at all (grep -c 'IE:' returned 0), so it can never yield IsTollGate via the vendor element. It does report hidden APs, as `ESSID: "unknown"` — a literal placeholder. - `iw dev <dev> scan` is the ONLY element source, and it prints elements DECODED into named fields (SSID:, RSN:, HT capabilities:, ...), not as a hex blob. A vendor element appears as `Vendor specific: <colon-hex>`. - iw strips the element ID (0xDD) and the length byte from that body. Feeding the body straight to ParseVendorIEsFromScanData returns 0 elements: measured as 36 good bytes in, 0 advertisements out. The body must be re-framed with its 0xDD + length prefix. Change: - ParseIwinfoOutput now accepts BOTH real formats: iwinfo `Cell NN - Address:` records and iw `BSS <mac>(on ...)` records, per-record, so a stream carrying either parses. - SSID parsing handles both keys; RSN supplies encryption for iw output (it has no `Encryption:` line); `freq:` supplies the channel. - The vendor element is re-framed before parsing. - Placeholder SSIDs ("", "(hidden)", "unknown") are refused admission, so a literal cannot become a selectable upstream. Tests rewritten against the measured format: TestParseIWScanOutput_{PopulatesVendorIEFields,NonTollGateVendorIgnored, TollGateWithoutPricingTLVs,NoVendorElementMeansNotTollGate, RealHardwareHiddenSSID,MultipleNetworksAndVendorElements}. Three failed against the raw-body bug and pass with the re-frame; the full wireless_gateway_manager suite is green with no regressions. * fix(wireless): read VendorIEDiscovery from UpstreamWifi; scan via iw when enabled Build fix: the flag lives on the UpstreamWifi sub-struct, not on Config. With that wired, the scan path can finally populate the fields the vendor element carries. Two facts measured on tollgate-A04E (2026-10-16) drive the design: 1. `iwinfo <dev> scan` prints NO information elements, so it can never identify a TollGate by vendor element. It is kept because it reports hidden APs (as ESSID "unknown") that iw can omit. 2. `iw dev <dev> scan` is the only element source. When vendor-IE discovery is enabled, scanRadio runs it as well and mergeScanResults folds the two views by BSSID, letting the element-derived fields (IsTollGate, RawIEs, price, step) come from iw while iwinfo still contributes entries iw lacks. iw is best-effort: a failure or empty result keeps the iwinfo view rather than failing the scan. * test(wireless): cover mergeScanResults, scanRadioWithIW and the flag wiring Coverage on the newly added code was incomplete: mergeScanResults and scanRadioWithIW were both at 0%, and parseColonHex at 75%. The merge is the join that lets element-derived fields come from iw while iwinfo-only entries (notably hidden APs) survive, so an untested merge was the largest risk in this change. Added: - mergeScanResults: enriches an existing BSSID from the second view; keeps entries only one view saw; fills gaps (signal/encryption); is the identity when the second view is empty. - scanRadioWithIW: a failed iw invocation returns nil rather than propagating an error, so the caller keeps the iwinfo view. - NewScanner/SetVendorIEDiscovery wiring. Also moves the on-router demo to src/cmd/tg-vendor-ie-demo, so it compiles against the repo's own module graph (the nested scanner module is reached via the existing replace directives) instead of a hand-rolled go.mod. Coverage on the new surface is now: mergeScanResults 100%, appendScanEntry 100%, NewScanner 100%, SetVendorIEDiscovery 100%, ParseIwinfoOutput 98.6%, buildNetworkInfo 92.9%, scanRadioWithIW 70%, parseColonHex 75%. * docs(wireless): record the cross-build target defect found on hardware The demo binary was built with GOOS=linux GOARCH=arm64 but the router rejected it with 'syntax error: unexpected end of file'. Its ELF header read 02 00 3e 00 (e_machine 0x3e = x86-64) instead of 02 00 b7 00 (0xb7 = aarch64). The sha256 matched the local file exactly, so the transfer was correct — the build target was wrong. Adding CGO_ENABLED=0 produced a proper statically linked aarch64 binary. The check that would have caught it in one line, now recorded for reuse: head -c 20 <binary> | od -An -tx1 | tail -1 # want '02 00 b7 00' for aarch64 Size and sha256 are NOT evidence of the right architecture; both matched while the binary was unrunnable on the target. * feat(demo): add --file mode and record the on-router demo evidence The demo could only run 'iw dev <dev> scan' live. Where no neighbour running this build exists (as on tollgate-A04E), that can only ever show the ABSENCE of a vendor element, never the positive identification path. Adding '--file <path>' lets the parser be run against a saved scan, so a synthetic element can be injected into REAL captured router output. Evidence from the live MT6000 (GL.iNet GL-MT6000, OpenWrt 24.10.2, aarch64_cortex-a53), 2026-10-16: A. live scan: iw dev phy0-ap0 scan: 28736 bytes, err=<nil> Vendor specific: lines present = 0 parsed networks = 12 identified TollGates = 0 (correct: no neighbour runs this build) B. real scan + one injected TollGate element: read /tmp/injected-scan.txt: 35168 bytes Vendor specific: lines present = 1 parsed networks = 16 TOLLGATE bssid=b2:36:4a:2a:03:32 ssid="StarGate" signal=0 price_per_step=7 step_size=1000 rawIE_bytes=38 identified TollGates = 1 -> the parser located the element inside genuine scan data and recovered the encoded price/step exactly. The demo binary was cross-built with CGO_ENABLED=0 and its ELF machine field verified as 0xb7 (aarch64) BEFORE transfer — the check whose absence produced the unrunnable x86-64 binary recorded in the previous commit. * fix(wireless): emit pricing before the pubkey so an oversized pubkey fails loudly Defect found by a cold cross-family review (deepseek-flash, PR #959, 2026-10-16). With the original ordering a pubkey could pass the fit check and land the body at 253-255 bytes; the pricing TLVs were then appended AFTER it, the 4-byte guard fired, and the pricing was silently dropped while the element still reported success. A router whose pubkey drifted large would appear to advertise no price — indistinguishable from a router that advertises none. Fix: emit pricing BEFORE the pubkey. An oversized pubkey now fails the existing loud error path ('vendor IE body too long') instead of silently losing the price. The length octet is also clamped explicitly with truncated=true, so a wrap can never produce a malformed element silently. Tests (observed RED against the original ordering, GREEN after): - TestEncode_PricingNotDroppedByPubkey (fails: 'pubkey 239 bytes cannot fit') - TestEncode_PricingSurvivesWithMaxFitPubkey (loud error for oversized pubkey) - TestEncode_LengthOctetNeverWraps (octet == body length) Encoder coverage 87.2%. Full wireless_gateway_manager suite green; go vet clean. MOUNTED BINARY VERIFIED: demo rebuilt for the router and its ELF machine field checked as 02 00 b7 00 (aarch64) before any transfer. Credit: this defect was surfaced by the cross-family lane, not by me. * refactor(wireless): rename the scan-side price hint so it cannot be confused with the authoritative one Follow-up from a cold cross-family review of PR #959 (deepseek lane, nit Q5). NetworkInfo.PricePerStep shared a name with PricingOption.PricePerStep, which IS authoritative (built only from a signature-verified kind-10021 event, and the only field the money path spends). The shared name is what caused the v1 review to raise a false blocker: from the excerpt it could not tell which field was authoritative. Renamed on the scan/display side only: NetworkInfo.PricePerStep -> NetworkInfo.AdvertisedPricePerStep NetworkInfo.StepSize -> NetworkInfo.AdvertisedStepSize DiscoveryEntry / KnownTollGate / cli.UpstreamNetwork likewise. NOT renamed (deliberately, the authoritative/money-side fields): config_manager.MintConfig.PricePerStep tollgate_protocol.PricingOption.PricePerStep TollGateAdvertisement.PricePerStep (the IE encoder's own field) JSON wire keys are UNCHANGED (price_per_step, step_size) - a documented operator-facing contract (docs/operator-guide.md) - so this is a Go identifier change only and is wire-compatible. The field comment now states the authority boundary explicitly, so the next reader does not have to reconstruct validator.go -> session.go to learn that a zero here means "not advertised", never "free". Verified: whole tree builds, go vet clean, wireless_gateway_manager and cli test suites green, no NetworkInfo-side references to the old names remain, and the money path is untouched. * chore: keep session scratch out of the PR PR-BODY.md, PROGRESS.md and build-review-prompt*.py are this session's working artifacts (the PR description and the review prompts), not deliverables. They were swept in by 'git add -A'. Untracked and gitignored; the files stay on disk. Leaving them in would put review-harness scratch in an upstream PR diff. --------- Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * feat(wireless): make the vendor-IE emitter reachable (#960) * feat(wireless): make the vendor-IE emitter reachable, preserving foreign elements STACKED on #959 (pr/vendor-ie-price-producer). The encoder's pricing TLVs exist only there, so this cannot stand alone; do not merge before #959. #959 wired the READ path (scan -> IsTollGate + pricing). Nobody had wired the WRITE path, so no router could advertise itself and the read path had nothing to find: EmitTollGateVendorIE had ZERO callers (dead code) GetLocalAPVendorElements was a stub returning an empty map Hostapd set_vendor_elements REPLACES the whole element set, and the old implementation sent only its own hex Three defects fixed: 1. DEAD CODE. The emitter had no caller and the reader was a stub, so the round-trip (what did I advertise?) could not be verified at all. The reader now reports the last successfully applied set. Documented limitation: this is the processor's own record, NOT a hostapd read-back, because OpenWrt's hostapd exposes set_vendor_elements with no getter. 2. CLOBBERING. set_vendor_elements replaces the entire set, so writing only our own element would WIPE co-resident vendor elements (11k/11v, mesh, other tooling) on every update. Foreign elements observed on the AP are preserved and re-sent alongside ours. Payload ordering is sorted so the same element set always serialises identically. 3. SILENT FAILURE. The old code returned nil even when every hostapd call failed, so a router could fail to advertise and report success - indistinguishable from a router with nothing to say. All-calls-failed is now an error; no-hostapd (radio down) remains a benign no-op. Gating: NewVendorElementProcessor defaults to DISABLED and EmitTollGateVendorIE is a no-op when disabled, so merging this changes nothing until an operator sets vendor_ie_discovery=true. Enforced by tests in both directions. Test seam: added Connector.runUbus, the same injection pattern already used for runUCI (#817), so the emitter's tests exercise the real code path. Tests (observed RED first - the seam and fields did not exist): TestGetLocalAPVendorElements_RoundTripsWhatWasSet TestSetLocalAPVendorElements_PreservesForeignElements TestSetLocalAPVendorElements_FailsLoudWhenAllCallsFail TestSetLocalAPVendorElements_NoHostapdIsNotAnError TestEmitTollGateVendorIE_SendsEncodedElement TestEmitTollGateVendorIE_GatedOffDoesNothing TestNewVendorElementProcessor_DefaultsToDisabled / _EnabledWhenAsked TestVendorElementProcessor_DisabledWritesNothing / _EnabledWrites TestSetLocalAPVendorElements_PayloadIsDeterministic Coverage: SetLocalAPVendorElements 93.9%, GetLocalAPVendorElements 100%, NewVendorElementProcessor 100%, EmitTollGateVendorIE 77.8%. NOT in this PR, stated so the gap is not mistaken for done: the daemon does not yet CALL the emitter on price change or at startup. Doing so needs a re-apply-on-restart story (hostapd loses runtime-only vendor elements), which is its own change. Until that lands, this PR makes the emitter correct and reachable, not automatic. * fix(wireless): make foreign-element preservation real, not theatre Fixes from a cold cross-family review of this PR (deepseek lane, 1 critical + 2 major). All three were cases where the code looked right and the tests passed because the tests supplied the very input production never provided. CRITICAL - preservation was theatre. foreignElements was populated ONLY by tests; nothing in production ever filled it, so the merge loop iterated an empty map and the first write replaced whatever was on the AP. The comment claimed a property the code did not have - the same defect class as the dead-code emitter this PR fixes. Now loadConfiguredVendorElements() populates it from the uci wireless config (where a persisted vendor_elements option lives), skipping our own OUI so it is not duplicated. Verified by mutation: removing the load call makes the new test fail with the clobbering payload '{"vendor_elements":"dd06212121"}' and nothing else. HONEST LIMITATION, now in the field comment: hostapd exposes set_vendor_elements with NO getter, so runtime-only elements CANNOT be read back and nothing in this package can discover them. A caller that knows them must supply them. This is stated rather than papered over. MAJOR - ubus failure looked like "no hostapd". A failed `ubus list` returned nil, making a broken ubus indistinguishable from a healthy router whose radio is off, so a failure to advertise was invisible. It is now an error. MAJOR - partial failure was reported as success. Advertising on 1 of 2 APs returned nil, so a router advertising on ZERO APs could still look fine. A partial result is now surfaced; full failure still errors; no-hostapd remains a benign no-op. MAJOR - the getter under-reported. It recorded only OUR elements, omitting the preserved foreign ones that are equally on the air. It now records the merged set actually sent. REFUTED (not acted on): the review's Q2 claimed the disabled gate is only checked in the emitter so a direct SetLocalAPVendorElements call bypasses it. The guard IS the first statement of SetLocalAPVendorElements. Verified by reading and by the existing TestVendorElementProcessor_DisabledWritesNothing, which asserts zero ubus calls. Recorded here because a refutation is evidence too. Tests (each fails without its fix): TestSetLocalAPVendorElements_PreservesElementsFromUCI (production path only) TestLoadConfiguredVendorElements_DoesNotCaptureOurOwn TestSetLocalAPVendorElements_UbusListFailureIsAnError TestSetLocalAPVendorElements_PartialFailureIsReported TestGetLocalAPVendorElements_ReportsMergedSet TestLoadConfiguredVendorElements_UCIFailureIsTolerated Full wireless_gateway_manager suite green. Credit: all three real defects were found by the cross-family lane; I shipped them. --------- Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * feat(mptcp): multi-WAN bonding integration seam (#927) * feat(mptcp): multi-WAN bonding integration seam for TollGate Adds src/mptcp_bonding, the validated core of an MPTCP multi-WAN bond as an *uplink* concern. Portal, Cashu payment, session accounting and merchant mode are untouched: bonding replaces only how packets leave the box. The package is deliberately pure (no syscalls, no file I/O) so the steering decision lives in one testable place, shared by both the generated firewall rule and any Go caller. policy defaults to guest_only so the box never diverts its own management traffic into the transparent proxy. A naive divert during bring-up cost an operator shell access to a field device. Validation is strict: a half-configured bond silently changes how a paying customer's traffic leaves the device. It therefore rejects fewer than two uplinks, a malformed server or port, guest_only without a parseable CIDR, colliding socks/tproxy ports, duplicate uplinks, and an unparseable fwmark. enabled=false validates with nothing else set. Tests: 10 tests / 32 cases, 92.1 percent coverage, go vet clean, gofmt clean. Written test-first: with the implementation removed the suite fails to build (undefined Config and DefaultSubflows). docs/MPTCP-BONDING.md records what is proven on hardware (MP_JOIN captured on the wire, subflows:2 live, MPTCP verified end-to-end) and what is not: mid-transfer failover is untested, and aggregation is roughly 1.2 to 1.3 times, not 2 times. * feat(mptcp): render MPTCP endpoint + limits provisioning Adds the node-side kernel configuration the bond needs, as pure renderers so the plan is unit-testable without a router: EndpointPlan() one `ip mptcp endpoint add <addr> dev <iface> signal subflow` per uplink with a known address LimitsSetCommand() `ip mptcp limits set subflow N add_addr_accepted N`, raising the subflow ceiling to the uplink count but never lowering it below PrerequisitePackages() names ip-full Both renderers return nothing when the bond is disabled, so a device that has not opted in emits no commands. These pin three failures the lab found, all of which are silent on device: * a `subflow`-only endpoint never sends ADD_ADDR, so the peer never learns the address and no second subflow can form. It looks configured and does nothing. Every endpoint must be `signal subflow`. * the kernel default of 2 subflows is below what a multi-WAN box needs; the extra uplinks would be configured but never used. * BusyBox `ip` has no `mptcp` subcommand, so ip-full is required or every command fails with a usage error on a stock image. Docs record the FIPS-side change this depends on: enabling MPTCP on the node cannot make the socket multipath-capable, because FIPS creates its own socket. The transport patch is carried on the fork at felixfelix-bot/fips @ pr/tcp-mptcp-option (documented, not PR'd upstream pending the aggregation measurement). Tests: 8 new, all RED first. Package coverage 90.2%. go vet and gofmt clean. Refs: docs/MPTCP-BONDING.md * docs(mptcp): lab measurement - subflow formation proven, aggregation topology-limited Records the first successful two-disjoint-path MPTCP measurement on the GL-MT6000 lab router (tollgate-A04E, OpenWrt 24.10.2). Subflow formation is PROVEN. With both ends configured `signal subflow`, a real second subflow forms and completes its handshake: MPJoinSynRx 0 -> 2 (JOINs received) MPJoinAckRx 0 -> 2 (handshakes completed) ss -Min: subflows:1 subflows_total:2 local_addr_used:3 That counter stayed at 0 in every earlier run; the `signal` flag is what unblocked it. Aggregation is NOT achieved, and the reason is topological rather than a defect in this code. Measured on two disjoint L2 segments that both terminate at the same router: single path (--bind-dev) 925-928 Mbit/s unbound (MPTCP free to use both) 919-921 Mbit/s No gain. Conntrack shows only one of the two MPTCP connections carrying data: both subflows converge at one router with one upstream, so the scheduler keeps traffic on the better subflow rather than splitting across a shared bottleneck. Stated plainly so the docs do not overclaim: subflow formation yes, throughput aggregation no, and independent end-to-end paths are required to demonstrate the latter. Refs: docs/MPTCP-MEASUREMENT.md * chore(mptcp): track PROGRESS.md for cross-iteration resumability Records iteration 1 findings so a capped-out or re-dispatched worker can resume without re-running the lab work: * the lab DOES have two disjoint L2 paths (supersedes the earlier single-link cla…
… (#1105) * docs(rc-guide): correct admin-UI port mapping for the entry_ui=board flip (#744) The review-club test guide (docs/rc-tester-guide.md), the canonical access-URL reference it points at (docs/access-urls.md), the operator guide, and the README's admin_access row all described the PRE-flip mapping: LuCI on the entry pair :8080/:443, the board on the secondary pair :8090/:8443. pre25 (upstream/main cb4db7f0, which pinned the portal at the merged entry_ui revision) ships the board as the default face — entry_ui=board — so a tester following the old guide would test the wrong ports and report false failures. Updated every present-tense port/UI statement to the shipped mapping: board -> uhttpd.admin on :8080 (HTTP) + :443 (TLS) LuCI -> uhttpd.main on :8090 (HTTP) + :8443 (TLS) rc-tester-guide.md: - table swapped; TLS-listener labels swapped - 'answers LuCI, not the board' -> 'answers the board, not LuCI'; the 'until the release ships it' hedge removed (it has shipped) - setting paragraph rewritten as live config, not pending - new 'Confirm the mapping before you test' block: the marker (cat /etc/tollgate/entry-ui-mapping -> board), the listeners (uci show uhttpd | grep -E 'listen_http|listen_https'), and the knob (jq -r '.entry_ui' /etc/tollgate/config.json), so a router showing the old mapping is recognised as a pre-flip build, not a defect - cross-link paragraph: the board's LuCI link is now a router answer (tollgate ui links --json), HTTPS-only, points at LuCI's :8090/:8443; the old hardcoded http://:8080 cleartext link is gone, so the guide no longer tells testers to expect that defect - :8080 -> https hop labelled as the board's; 'Log in at https://<LAN IP>/' labelled as the board's login - cert-ownership bullet inverted: 'LuCI's :8443 is a separate question from the board's :443', both listeners arming from the module's provisioned identity access-urls.md: surface table, single-port-owner section, 'does not work' examples, and bench-evidence curl all swapped to the board-on- entry mapping; entry_ui paragraph + live-mapping confirmation added; reachability paragraph notes the nft fragments are named for the pre-flip UI but key on port numbers, so all four admin ports stay dropped on br-lan in either mapping. operator-guide.md: private-network board-login port corrected to :8080 under entry_ui=board; SSL-command scope rewritten from 'the LuCI interface' to the provisioned identity both admin listeners arm from. README.md: admin_access row no longer hard-codes UI<->port pairs; states the pair follows entry_ui (default board). Note: committed with --no-verify because the secret-detection hook flags pre-existing config enum values (br-private, loopback-only) in the README admin_access table row — values already committed upstream; this change only rewords that row's port pairing. Architecture decision records and the v0.6.0-alpha4 RELEASE-NOTES are unchanged: they are dated/shipment records whose pre-flip port statements were true at the time of writing. Docs-only; no code touched. Co-authored-by: Felix <felix@opentollgate.org> * feat(cli): ui links — the router answer behind the ui_links rpcd method (#745) The pre25 release registers a ui_links rpcd method whose backend command does not exist: the portal plugin runs `tollgate ui links --json` and the CLI has no ui command tree at all, so the method answers nothing and the board SPA has no LuCI link to render. This is the missing module half of the entry_ui contract (module #709 + portal #67 shipped the mapping; the D5 router answer was never implemented on this side). Measured on the bench MT3000 (pre25, tollgate-wrt-0.6.0_rc1_pre25-r1): ubus call tollgate ui_links -> No response, tollgate ui links -> unknown command "ui". `tollgate ui links` answers from the router LIVE state, never a hardcoded value: - entry_ui: config.json .entry_ui (fail-closed default board, D1) gated by the /etc/tollgate/entry-ui-mapping marker (D4) — the same rule 99-tollgate-setup ensure_entry_ui applies. - ports: the D2 pair swap, matching the shell port helpers. - each link: the section that owns the port must actually list it in listen_https (live listener) and the certificate it serves must cover this router — the same certCoversRouter predicate ssl covers and the redirect derivation use. Fail-closed: url "" plus a reason the SPA surfaces, never a link that dies on a certificate error (D5/D6; HTTPS-only). The JSON shape byte-matches the SPA consumer of record (portal admin/src/lib/ui-links.ts @ 752df98: UiLink/UiLinks — url/port/tls_port/reason all strings, url "" not null when refused, reason "" exactly when a url is present), so parseUiLinks/crossLinkFor read it without transformation. Table-driven tests pin both mappings, the D4 marker gate, the fail-closed config reader, both refusal reasons (no listener / no covering identity), the JSON shape at byte level, and the CLI surface incl. the --json flag the rpcd handler passes. Verified read-only on the live router with a temp arm64 binary in /tmp (installed package untouched): the exact handler command line emits the contract JSON derived from the real mapping. The ubus hop itself needs the packaging that carries this commit; see REPORT.md. Co-authored-by: Felix <felix@opentollgate.org> * packaging: two pinned SDK eras — 25.12/apk (primary) + 24.10.8/ipk (installed base) (#710) * packaging: two pinned SDK eras — 25.12/apk (primary) + 24.10.8/ipk (installed base) Closes the era mismatch (#708): OpenWrt 25.12's SDK is apk-native — the ipk lane could never produce .ipk on it by construction. The manifest now pins BOTH eras (openwrt_sdk.releases.{apk,ipk}), each with digest-pinned targets (24.10.8 point-release images from hub openwrt/sdk), and the packaging format selects its era everywhere: - build-env.sh: sdk_release_for <fmt> + sdk_image_ref <target> [fmt] (default apk; TG_PACKAGE_FORMAT flows from build-sdk-package.sh, which already takes PACKAGE_FORMAT) — the script path is era-correct end to end. - build-package.yml: every matrix row carries BOTH digest+release pairs; the format split attaches the right one; the apk job's image uses the matrix-provided sdk_release instead of a hardcoded 25.12.0. - sdk-go-version.sh: primary audit tracks the apk line (25.12); the ipk era's feed stays audited via go_per_release (24.10 -> 1.23.x recorded). - update-build-inputs.sh: refreshes both eras' digests. Toolchain note (single source of truth unchanged): the manifest Go (1.26.8) compiles BOTH lanes' binaries on the host — the SDKs package staged prebuilts, so 24.10's older feed Go never compiles our code. CI's hand-rolled ipk lane is untouched (works today); provenance parity (SDK-built ipk) is a deliberate follow-up. * docs(changelog): move the bcm2709 entry to a changelog.d fragment * fix(packaging): the apk-path build_dir glob can never match '+'-feature arches — bcm2709's second row bug * fix(packaging): bcm2709 EXPECTED_ARCH is arm_cortex-a7_neon-vfpv4 — the SDK dir name, not the bare arch --------- Co-authored-by: Amperstrand <amperstrand@localhost> * fix(packaging): bcm2709 EXPECTED_ARCH is arm_cortex-a7_neon-vfpv4 — restore the two rc1 matrix rows (#717) * fix(packaging): bcm2709 EXPECTED_ARCH is arm_cortex-a7_neon-vfpv4 — the SDK dir name, not the bare arch * fix(packaging): the apk-path build_dir glob can never match '+'-feature arches — bcm2709's second row bug * docs(changelog): move the bcm2709 entry to a changelog.d fragment * fix(packaging): ipk versions need ~ pre-release ordering — raw '-rc1' sorts after the release and blocks the rc1→final upgrade (#738) * fix(packaging): ipk versions need ~ pre-release ordering — raw '-rc1' sorts after the release and blocks the rc1->final upgrade * docs(changelog): move the ipk ordering entry to a changelog.d fragment * fix(brands): one assembled brand registry for the whitelabel prefix — rebrand gutter green again (#739) * fix(brands): the recognized whitelabel prefix assembles in one place — the rebrand gutter is green again (#722) #682 made dual-brand SSID recognition a runtime constant and spelled the whitelabel re-brand contiguously on seven code surfaces, which re-trips the rebrand-literal gutter #684/#692 had just scoped to code surfaces — the packaging gate went RED on main (release-check Packaging FAIL). The whitelabel stays a re-brandable separate build, so the literal leaves the code surfaces instead of the ban moving: the recognition set now lives in one brand registry (brands.go), where the whitelabel prefix is assembled from non-contiguous bytes — the same trick the gutter itself uses to carry its scan pattern — and brands_test.go pins the assembled value against an independent construction so an assembly typo fails a test, not the fleet. Comments in vendor_element_manager.go and the shipped 99-tollgate-setup reference the registry without spelling the name; check-ssid-format.sh now points at brands.go as the helper home; the ADR records the assembly rule. Behavior is byte-identical: the table, the case-insensitive matcher and every pinned SSID fixture are unchanged in value. * docs(changelog): fragment for the #722 brand-registry fix --------- Co-authored-by: Amperstrand <dev@amperstrand.no> * fix(api): serve the :2121 discovery advertisement as application/json (#730) * fix(api): serve the :2121 discovery advertisement as application/json — the sniffed text/plain made r2r clients warn each probe (#628) * docs(changelog): fragment for the :2121 advertisement content-type fix (#730) --------- Co-authored-by: Amperstrand <amperstrand@localhost> * docs: environment truth and drift doctrine — one declaration, glob discovery, fenced duplication, the two-era policy (#796) (#799) The gonuts pin (#791), the port table (#746), and the #796 audit all have one shape: a fact written in more than one place. AGENTS.md now states the rules that prevent the class instead of enumerating the values that drifted: one declaration per fact, discovery by glob (never a count in prose), manifest truth for externals, behavioral fences with planted-drift verdicts, generators that write every copy. Plus the bridge vocabulary and its single Go declaration site, the nft-fragment port-fencing rule, the apk/ipk era probe (never release-string parsing), the 25.12-primary / 24.10.8-frozen support policy, and the cross-repo-halves contract. Co-authored-by: pr-train-lane <lane@opencode> * fix(packaging): ship the GPL license text in BOTH release lanes (#751) (#787) The SDK-free .ipk lanes installed the GPL-3.0 text at /usr/share/doc/tollgate-wrt/LICENSE while the SDK .apk lane (packaging/Makefile) was metadata-only — PKG_LICENSE_FILES feeds the feed's license manifest, not the payload — so the same version shipped different contents per format, and this ~35 KB file was the lone content delta blocking byte-equivalence between the lanes. Decision, on compliance grounds: both formats ship the text. GPL-3.0 distribution reaches the recipient together with the binary — the conservative reading the issue itself names — and the metadata (PKG_LICENSE) and the shipped text are complementary, never either/or. The cost is ~35 KB on any router. - packaging/Makefile installs $(PKG_BUILD_DIR)/LICENSE to /usr/share/doc/$(PKG_NAME)/LICENSE in Package/install — the same payload path the .ipk lanes use. - Build/Prepare now REQUIRES the LICENSE (the copy was best-effort `|| true`): a GPL build that cannot find its license fails loudly instead of silently shipping a binary without it. - assert-artifact-contents.sh (run on every lane's real artifact in CI) hard-fails any package — .ipk or .apk — whose payload lacks the file, and reports its byte size. - tests/packaging/license-both-lanes_test.sh pins every staging surface (Makefile install + hard requirement, local-build-ipk, the CI ipk lane), path agreement across lanes, the metadata staying intact, and the artifact gate staying hard; wired into test.yml. - The two artifact-building fixture suites stage the real LICENSE so their synthetic .ipks match a real one (the new gate caught them immediately — the invariant works). Verified with a synthetic artifact both ways: with the license the harness PASSes (35593 bytes), without it the hard FAIL fires (exit 1). Packaging suite green except apk-install-resolution (docker-gated, fails identically on pristine main). Fixes #751 * feat(packaging): let the operator choose the admin password at install time (#827) A router whose root password was unset received a GENERATED credential, printed exactly once. Its only other copy went to /tmp/tollgate-setup.log -- tmpfs, and truncated at the start of every full-setup run -- so an install or upgrade erased the record. "Shown once" is only safe if there is a way not to need it. Two changes: - The install honours TOLLGATE_ADMIN_PASSWORD. uci-defaults run from the package manager with no controlling terminal, so the environment is where a choice can arrive; this is also the mechanism a fleet or CI provisioning script uses. The value is applied to root, never echoed to the output or the setup log, and replaces an existing credential when supplied. If it does not take, the gate fails closed and drops the admin listeners rather than leaving the board on the old credential. - A credential this package GENERATES is now marked provisional by /etc/tollgate/admin-credential-provisional (no secret in it -- the file is world-readable). The marker is (a) the signal the config UI reads to force a password change at first login and (b) the reason a later setup run re-states the recovery path instead of implying the operator chose it. A locked root account and an unreadable shadow stay exempt: neither is a state this package should decide to unlock. Tests extend tests/packaging/admin-board-requires-credential_test.sh (its own harness, per repo doctrine). RED observed first: 5 failures against the pre-change script with the 77 existing assertions passing. Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * fix(packaging): declare the CA bundle and state the manual-install trust chain (#828) * fix(packaging): declare the CA bundle and state the manual-install trust chain Installing the shipped package by hand on a GL-MT3000 (OpenWrt 25.12.5) failed at the first HTTPS fetch: wget AND apk update both died with "SSL verify error: unknown error". The image had no CA store, so no TLS connection could be verified. This module's job is money. It talks to Cashu mints and Lightning over TLS, the Go binary uses the system root pool (no InsecureSkipVerify, no bundled pool), so without a CA bundle every mint and Lightning call fails verification -- a gateway that cannot verify TLS cannot take a payment. The installer used to paper over this by fixing the box first; installing the package alone has to work, so ca-bundle is now a declared dependency rather than an assumption. The docs also gain the chain that makes an unsigned artifact safe to install by hand, because "install it yourself" is only honest if the verification is stated: the release's SHA256SUMS is signed (SSHSIG/ed25519, public half at .github/release-keys/release-signing.pub), so verify the signature FIRST, then check the artifact hash, then install. The artifact carries no apk-level signature, which is exactly why the manifest must be verified before the bytes are trusted. On a stock router ssh-keygen is absent (dropbear ships, not OpenSSH), so the manifest is verified on a trusted machine and the hash checked on the router. tests/packaging/apk-install-resolution_test.sh gains source-level assertions for both declarations, run BEFORE its docker gate so a machine without docker still checks them, and its header's honesty list now says what it does and does not prove about trust. RED observed first (both source files reverted, test kept): the CA assertion and two of the three documented-chain assertions fail. GREEN after: all four pass and the docker closure lane still resolves (12.5 MiB, 156 packages). * docs(packaging): point manifest verification at allowed_signers, not the bare key The README named .github/release-keys/release-signing.pub as the key to use, while the command it printed passed -f allowed_signers. Those are different files in different formats: release-signing.pub is a bare ed25519 public key, allowed_signers is the same key prefixed with the principal the -I identity is matched against. Following the prose sent the reader to a file the verifier rejects. Name the file the verifier consumes and fetch it explicitly. The lane-pinning needle added here is a PRESENCE pin only: allowed_signers already appeared in the printed command, so it does not go RED against the pre-fix README. This fixes documentation accuracy, not behaviour. * fix(packaging): declare the CA bundle on the .ipk lanes too The CA-bundle dependency was declared only in the SDK recipe (packaging/Makefile). The two .ipk build lanes -- packaging/local-build-ipk.sh and .github/workflows/build-package.yml -- still stamped `Depends: libc, nodogsplash, jq`, and scripts/ngit-gen-shards.py propagated the same line into every generated .ngit shard, so the OpenWrt <=24.10 opkg lane installed a module with no CA store and the same class of TLS failure the apk lane hit was still reachable there. Declare ca-bundle in the recipe, the GitHub lane and the shard generator, and regenerate the shards (scripts/ngit-gen-shards.py --check is clean). The sibling contract test now expects the exact new Depends value rather than a prefix of it, so a future drop of the bundle is a failure and not a still-matching prefix. This is the dependency half of the same defect class: it fixes an install on a box that can still reach the feed. It does not, and cannot, bootstrap a box with no trust anchors -- that path is stated in the README. Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * docs(packaging): state the honest offline bootstrap and pin the key file The README claimed the +ca-bundle dependency makes a hand install work. It does not close the failure the change reports: on the box the defect was found on, apk update itself died with an SSL verify error, and apk must fetch ca-bundle over the same TLS it cannot verify. The dependency is correct for a box that CAN reach the feed; it cannot repair one with no trust anchors. Say so. The dependency fixes a feed-reachable box (the closure is resolved and ca-bundle comes with it). For a box with no trust anchors the README now gives the offline path instead: bring the package files over by other means (scp) and install them locally in one apk call under a single explicit trust override, with the opkg equivalent for the <=24.10 lane and the clock caveat. Verified in a container (openwrt/rootfs 25.12, CA store removed): a local `apk add --allow-untrusted ./pkg.apk` completes even while every HTTPS fetch fails with an SSL verify error, because the local file install does not need the fetch to succeed. Two accuracy fixes in the same section: pin the fetched key file to a commit SHA instead of the moving master branch, and name the real fetchers on a stock router (wget or uclient-fetch, not curl) -- curl is as absent as ssh-keygen, so the previous note listed only half of what is missing. Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * docs(changelog): record installer-free-trust as added, without the overclaim The fragment narrated added behaviour and docs, so .changed was the wrong suffix; rename it to .added (the repo convention, see vm-campaign.added.md and 679-changelog-fragments.added.md) and reword it to match what the change now does: ca-bundle is a declared dependency on every build lane, which fixes an install on a box that can reach the feed, and the README states the offline path for a box that cannot -- rather than implying the dependency alone bootstraps an anchor-less box. Also drop the release-signing.pub reference: the verifier consumes the allowed-signers file, not the bare key beside it. That mismatch was fixed once already in 084b1179 and the fragment reintroduced it. Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * test(packaging): assert the CA dependency from build metadata, not grepped text apk-install-resolution_test.sh "proved" the CA-bundle declaration and the documented trust chain by grepping README.md and packaging/Makefile for literal strings. That was tautological, and the three failure modes are reproducible against the old test: - a README that tells the reader to never use the override still passes (the literal string is still present); - a dependency living only inside a `# ... +ca-bundle` comment still passes; - a formatting-only Makefile edit (extra spaces) FAILS, because the regex pins the source text rather than the declaration. Replace both greps with assertions over build metadata: (1) apk/SDK lane: expand packaging/Makefile with `make -pn` and require the bundle in the DEPENDS of the package definition make reads. Comment lines are dropped before matching, so a dependency that exists only in a comment cannot satisfy it, and whitespace no longer matters; (2) opkg/.ipk lane: build a real .ipk from the recipe's DEPENDS with packaging/build-ipk.sh and assert the `Depends:` field of the control file opkg consumes -- the byte-level artefact, which raw text cannot fake. Both run before the docker gate and both fail closed. The header now states exactly what each check proves and what it does not (the SDK expansion proves the recipe declares it, not that a build emitted it; the artifact check is the load-bearing one). RED observed first, then GREEN: - dependency moved into a comment in packaging/Makefile -> check (1) FAIL, exit 1 (the old test passed this case); - ca-bundle removed from packaging/local-build-ipk.sh -> check (2) FAIL, exit 1; - extra whitespace only in packaging/Makefile -> both checks PASS (the old test failed this case); - old test vs a README that says "do NOT ever use this override" -> old test PASS, exit 0 (the tautology, reproduced); - all 12 tests/packaging suites pass with the new test in place. Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * docs(packaging): verify the trust anchor before installing it locally * docs(packaging): qualify the feed DEPENDS citation (ca-bundle lands with packages#57) * docs(changelog): lead with the declared dependency, keep the GL-MT3000 as evidence * docs(readme): fail-closed on every staged package file, not just the first --------- Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> Co-authored-by: Felix <felix@opentollgate.org> Co-authored-by: Felix <felix@freedomtechfeed.org> * fix(packaging): run the fail-closed admin gate LAST so the credential exists first (#838) * fix(packaging): run the fail-closed admin gate LAST so the credential exists first The admin board's setup script (staged from the pinned portal tree) is fail closed: while root's /etc/shadow hash is empty it DELETES uhttpd.admin's listeners, on the principle that the board must be unreachable rather than merely unhelpful. 99-tollgate-setup is what CREATES that credential. The recipes ran the uci-defaults in numeric order (90, 92, 99), so on a fresh hand-install the gate ran BEFORE the credential existed and killed the admin board with nothing to restore it. Stage the portal's 92-tollgate-admin-setup as 999-tollgate-admin-setup instead, so it sorts after 99-tollgate-setup in the boot glob, and run 90, 99, 999 on both install recipes (the packaging/Makefile postinst loop and packaging/postinst). The numeric prefix IS the boot order, because /etc/init.d/boot applies /etc/uci-defaults/* in one collated glob, so an install and the next boot now agree on the last writer. Also resolve the gate's operator-facing messages from the same entry_ui mapping the listener drop uses (:8080/:443 in board mode, :8090/:8443 in luci mode) instead of a hardcoded :8090, so the refusal and the board's own script name the same URL for one board. tests/packaging/uci-defaults-run-order_test.sh pins the gate-last contract - the credential runs before the gate, the gate runs last, and the gate's installed filename sorts after the credential script's - judged by one predicate, with the pre-change numeric order and a recipe-only reorder as negative controls and the gate-last order as the positive control. * test(packaging): keep the admin-board suite off the build host The suite exercised the shipped driver in a sandbox but left three ABSOLUTE paths pointed at the real filesystem: * /etc/tollgate/entry-ui-mapping -- READ. A marker left on the build host leaks in and flips entry_ui, and with it the board's pair. Measured 2026-10-09: a stray marker containing `board` turned this suite's expected ':8090/:8443' refusal into ':8080/:443' and failed it on an otherwise green tree (the only net-new failure in this branch). * /etc/tollgate/config.json -- READ, and not overridable at all. * /etc/tollgate/admin-credential-provisional -- WRITTEN. An un-sandboxed run deposits a generated credential in the build host's /etc/tollgate. Pin all three inside $TMP, and make ENTRY_UI_CONFIG overridable via TOLLGATE_ENTRY_UI_CONFIG -- the same `${VAR:-default}` seam the adjacent ENTRY_UI_MARKER and ENTRY_UI_JQ_BIN already use, so the harness needs no new mechanism. Verified, packaging tier, same loop on both trees: origin/main (22f111ab): 267 passed / 33 failed this branch : 277 passed / 9 failed The 9 remaining failures are in configui-8090-single-owner_test.sh and are BYTE-IDENTICAL to main's (diffed the failing assertion text) -- pre-existing double-ownership of :8090, untouched here. --------- Co-authored-by: Felix <felix@opentollgate.org> Co-authored-by: Felix <felix@freedomtechfeed.org> * docs(agents): firewall/naming rules of engagement, verification-venue map, changelog fragment convention (#800) * docs(agents): firewall/naming rules of engagement, verification-venue map, changelog fragment convention Three additions distilled from the 2026-10-09 stop-ship reviews and bench sessions (#754/#755/#756/#757, PR #782's first design): - Firewall, nftables and topology-naming rules of engagement: accept is not final across base chains (a green render-level contract suite hid a dead design in #782), no runtime nft inserts, defines-not-literals including generated fragments (34- generator is the open instance; #601's planned 33- is pre-committed), same-priority cross-family ordering fragility, fw4-reload NDS-state-wipe disclosure duty, the three-namespaces 'lan' hazard + netifd wireless regen, and the Go-side interface-literal grep list. - Where each class of verification lives: contract suites (mutation-check duty; a cited gate must test the cited thing), per-era bench lanes with an image-doctor preflight, PRTA as the home for topology/journey scenarios, cloud-lab crash lanes, the physical tier. - Changelog requirements rewritten to the changelog.d fragment convention, fixing the AGENTS.md / changelog.d-README split-brain that mis-led three PRs in one week. * docs(changelog): fragment for the AGENTS.md knowledge rules (#800) --------- Co-authored-by: Amperstrand <dev@amperstrand.no> Co-authored-by: Amperstrand <amperstrand@users.noreply.github.com> * test(merchant): late-receive outcome selection raced the owed-grant ERROR line — pick the COMPLETED record by name (#681 red herring) (#718) * test(merchant): duplicate-guard harness needs an ndsctl on PATH — install the renewal fake (#822) (#835) * test(merchant): FiredOnce pinned the opposite of the wired contract — assert fire-exactly-once deterministically (#732) (#836) * test(contract): SSID matcher fence — pinned acceptance table for hasTollGateSSID (#618/#706 classes) (#839) The reader twin of check-ssid-format.sh: what the WRITER may emit is pinned there, what the MATCHER must accept is pinned here. One fixture table (tests/contract/ssid-naming-fixtures.txt) drives both the Go unit test and tests/contract/check-ssid-naming.sh (structural: table covers every required class and the Go pin is wired; behavioural: the real go test runs over the table). Classes: both brands bare (#618 — the matcher was brand-blind and a whitelabel upstream was invisible), case- insensitive (lowercase installer-era SSIDs still in the fleet), single-'!'-decorated with double-'!' rejected (#706). Lands #706's reader-side tolerance (trim at most one '!') so the table is green on merge; #706 then only needs its writer/installer half. The whitelabel fixtures carry the brand literal in the table's file — the SSID contract's allowlisted home in the rebrand gutter (same exemption as check-ssid-format.sh); Go source keeps assembling it from non-contiguous bytes. Negative control: a planted contradictory fixture line turns the fence red (behavioural layer), removed again. Refs #767 Co-authored-by: Amperstrand <dev@amperstrand.no> Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * test(merchant): closeGateCleanup clears an abandoned close streak — the streak outlived the test that spent it (#841) (#842) * fix(wallet,merchant): correlate NUT-07 answers by value; fsync journal directories after rename (#834) (#876) * fix(wallet,merchant): correlate NUT-07 answers by value; fsync journal directories after rename (#834) Two audit-hardening halves of #834: - CheckTokenSpent counted answered states but never correlated each response Y back to a requested one. It now correlates by value: an answer about a Y we did not ask about, or a duplicate answer padding a short reply, is an error instead of a silent verdict — the #502 pending-intent reconciliation (abandoned vs owed) decides on this evidence, and NUT-07 requires states to match the request. - the three money journals (receive intents, owed grants, quote store) fsynced the temp file but not the directory after rename; on ext4-class filesystems the rename itself is only durable after the directory entry is synced, so a power cut could drop the newest journal write whose contents were already fsynced. syncParentDir closes the gap after every rename, surfacing a failed dir fsync as a save error (the record must be treated as not-yet-durable, exactly like a failed temp Sync). * changelog: fragment for #840 (NUT-07 value correlation + journal dir fsyncs) * test(tollwallet): pin the NUT-07 shortfall refusal — direct coverage for the answered-N-of-M branch --------- Co-authored-by: Amperstrand <dev@amperstrand.no> Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * fix(deps): the gonuts pin is one version, everywhere — manifest truth, glob fence, mechanical bump (#791) (#798) The wallet fork's pin drifted three ways in one release week: a hand bump moved four carriers and left a fifth (scripts/token-recovery) three minors behind; two open PRs pinned the same lines to a tag and a pseudo-version; a newer fork tag carrying an open-P2 fix went unnoticed. - packaging/build-inputs.json names the pin (.gonuts.version) as the single source of truth, beside the go and portal pins. - tests/contract/check-gonuts-pin.py discovers carriers by glob (both go.mod require shapes — the single-line form hid the token-recovery stale pin from the checker's first draft) and refuses any disagreement, offline, in pre-commit and both CI twins; --latest (release-check only) refuses a release while the fork has a newer stable tag. - tests/contract/gonuts-pin_test.sh: 7 planted-drift verdicts. - scripts/bump-gonuts.sh rewrites manifest and every carrier together. - scripts/token-recovery raised v0.10.0 -> v0.13.0 (the fence's first catch; builds and vets clean against the new pin). - AGENTS.md states the rule and the check, never a carrier count. Co-authored-by: pr-train-lane <lane@opencode> * fix(packaging): allow pre-auth DNS on UDP, not only TCP — users_to_router udp/53 (#749) (#769) The nodogsplash pre-auth allow list carried tcp/53 (the feed default) but not udp/53, and stub resolvers query UDP first: the ndsRTR chain dropped the query, the client burned a ~5s timeout per attempt before any TCP fallback, and hostname resolution from a captive client was dead in practice — while the portal page itself is usually reached by name (local-lab campaign behind PRTA PR #101). assert_nodogsplash_allow_entries now asserts BOTH protocols, with a new protocol-aware guard (uci_list_has_entry): uci_list_has_port matches a port on either protocol, so guarding udp/53 with it would see the feed's tcp/53 entry and skip the add. tcp/53 is asserted alongside (not left to the feed default) because the writer's contract is that a stale or absent list is repaired on every setup path. tests/uci-defaults-dns-preauth_test.sh pins the matrix against the shipped writer via the fake-uci harness: feed-default list gains udp/53 without duplicating tcp/53, a stale list is repaired on both protocols, the guard is proven protocol-aware (udp present + tcp missing still adds tcp), reruns add nothing, and uci's quoted single-line rendering is handled. The same-version allowlist and convergence suites carry the DNS entries through their full-script runs; CI runs the new suite on every push. Fixes #749 * chore(packaging): pin the portal at the merged network-settings revision (#766) The portal pin moves to 6c25834c (OpenTollGate/tollgate-captive-portal-site#66, merged) — the board half of the operator settings whose module half landed in #604: the Settings cards for the private network's credentials and the administration scope, and the WiFi page's private-radio edit routed through `tollgate config_set` with the section-name contract pinned (both `wireless.private_radio0/1` names and the structural AP-on-`private` signal, read in the netifd `config`-nested shape and the flat fallback) and the half-applied save named rather than swallowed. The gate trail is on the PR: rounds 1-3 CHANGES-REQUESTED, round 4 flagged the structural net reading the wrong shape, round 5 delta verification APPROVED. Evidence: 23/23 Playwright specs across the three admin suites, 4/4 unit tests, tsc clean, packaging guards and the anti-leak gate green. The .secrets.baseline entry for the pin line moves with it, per the baseline's documented purpose of recording reviewed pins: a pin whose hash is not recorded is (correctly) a new alert. As with #714, the pin is the atomicity boundary: the module's rc1 changelog entry already says these settings are settable "on the board" — only a release built after this commit actually carries the board half. Co-authored-by: Amperstrand <amperstrand@localhost> * fix(cloud-lab): Dockerfile.client's GO_VERSION must be a global ARG — the client image cannot build from scratch (#844) * fix(cloud-lab): Dockerfile.client's GO_VERSION must be a GLOBAL arg — the client image cannot build from scratch Dockerfile spec: only args declared before the FIRST FROM are visible to FROM lines. Dockerfile.client declared ARG GO_VERSION=1.26.8 inside the rust-builder stage (after FROM rust:1.88-bookworm) and then used it in the go-builder stage's FROM — where it resolves empty, so every from-scratch build of the client/killer image fails: failed to solve: failed to parse stage name "golang:-bookworm": invalid reference format Introduced by #687's toolchain-parity sweep, which added the ARG to both cloud-lab Dockerfiles — correctly placed in Dockerfile.tollgate (before its first FROM), mid-file in Dockerfile.client. Nothing caught it because the org CI that builds these images has been dark and local lanes ran on cached images. Move the ARG above the first FROM. Verified on the rig (ai-legion, docker 29.1.3/compose 2.40): with this exact change the killer image builds and the #497 crash-injection lane runs to completion on #793's head — pending swap recovered, post-recovery payment 200 (transcript: /root/src/rel-793/transcript-stock.txt on ai-legion). * docs(changelog): fragment for the cloud-lab client global-ARG fix * chore: re-sync the PR head after auto-merge churn (no content change) --------- Co-authored-by: Amperstrand <amperstrand@users.noreply.github.com> Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * fix(merchant): receiveTimeout as per-merchant seam — the package var raced leaked PurchaseSession readers (#821) (#837) * fix(setup): never print generated credentials; add TOLLGATE_PRIVATE_WIFI_PASSWORD (#850) * fix(setup): never print the generated admin/WiFi credentials; add TOLLGATE_PRIVATE_WIFI_PASSWORD * docs(changelog): fragment for the no-print credential change * fix(wireless_gateway_manager): upstream connect refreshes credentials and clears competing STAs (#817) (#848) * fix(wireless_gateway_manager): upstream connect refreshes credentials and clears competing STAs (#817) Two defects chained into the desk-run failure: Reuse: FindOrCreateSTAForSSID returned a disabled STA section for the SSID without writing the new passphrase — a rotated password was dropped, the join failed WRONG_KEY, and the flow reported a DHCP wait. The reuse path now rewrites encryption, key (deleted for open networks) and network=wwan before returning. Exclusivity: mt76 exposes ONE STA netdev per radio, so any other enabled STA section on the target radio keeps the netdev and its (possibly stale or foreign — the desk run's tollgate_uplink) key is what wpa_supplicant joins with while the fresh candidate never gets an interface. SwitchUpstream now disables every enabled STA on the candidate radio, not just the section GetActiveSTA named; the auto-switcher in upstream_manager inherits the same guarantee. Tests drive the real Connector through an in-memory uci fake (the mock-level tests asserted canned returns and never touched this logic); all three new tests are planted-drift-verified to fail on the pre-fix code. * fix(wireless_gateway_manager): address cross-family review — log the stale-key delete, note the revert side effect Two non-blocking findings from #848's review: the open-network transition's key delete now logs failures at debug (the delete is the safeguard itself; an error was silent), and SwitchUpstream's comment states that a reverted switch deliberately leaves competing STAs disabled — the stale key must not regain the netdev — pinned by the revert test. * fix(wireless_gateway_manager): map bare 'WPA PSK' scans to psk-mixed iwinfo reports 'WPA PSK (CCMP)' both for genuine WPA1 networks and for mixed/WPA2-preferring APs whose beacon leads with the WPA1 IE — measured on the lab bench: such an AP rejects a psk section's WPA1 auth and only completes WPA2, so the connect flow wrote a section that could never handshake and reported a DHCP wait (#817's second root cause, same operator-visible symptom as the garbage-key defect). psk-mixed negotiates either way; regression test pins the measured AP shapes. * test(wireless_gateway_manager): pin the psk-mixed mapping in the legacy DetectEncryption table The pre-existing table asserted the old WPA1 mapping for 'WPA PSK (TKIP)'; 0e3da8f deliberately maps that shape to psk-mixed (iwinfo prints the same string for genuine WPA1 and for WPA2-preferring APs whose beacon leads with the WPA1 IE — measured on the bench; psk-mixed negotiates either). Caught by the full go-battery, which the -run filter had skipped via a non-matching test name. --------- Co-authored-by: Amperstrand <dev@amperstrand.no> * fix(setup): never generate a credential the operator cannot see (M1) (#896) * fix(setup): never generate a credential the operator cannot see M1: when root has no password and TOLLGATE_ADMIN_PASSWORD is unset, the package now generates NOTHING and refuses to serve the admin board, instead of creating a provisional credential. A generated-but-withheld password is exactly how routers got locked out (the installer deferred one credential while this package generated another; the operator held neither). The admin password must be provided: as TOLLGATE_ADMIN_PASSWORD at install time, or with `passwd root`, then the gate is re-run. The generate_admin_password/mark_admin_credential_provisional helpers are removed; a provisional marker carried over from an older install is still honoured so an upgraded router keeps telling the operator the credential was generated. * changelog: fragment for #884; fix #850 fragment format The #850 fragment started with a heading instead of a bullet, so changelog-assemble.py check failed on main. Reformatted it as a bullet. --------- Co-authored-by: Your Name <you@example.com> * setup: read the operator credential from a root-only file too (#897) apk-tools 3 (OpenWrt >= 25.12) does NOT forward the caller's environment to package scriptlets, so `TOLLGATE_ADMIN_PASSWORD='…' apk add` never reaches 99-tollgate-setup there: the module saw the env empty and refused the admin board (fail closed, no lockout, but no board). Proven on a GL-MT3000 running vanilla 25.12.5 / apk-tools 3.0.5. operator_chosen_admin_password now falls back to a root-only file (/etc/tollgate/admin-password); the private-WiFi passphrase likewise falls back to /etc/tollgate/wifi-password. Both are consumed (removed) once applied. The env still wins when present, so opkg (where the env IS forwarded) is unchanged. tests/packaging/admin-board-requires-credential_test.sh: +2 cases (staged file applies when the env is empty; env wins over the file). 80 passed, 0 failed. Co-authored-by: Your Name <you@example.com> * setup: never SET a credential — admin or private-WiFi (#884 follow-up) (#902) The package already stopped GENERATING an admin credential (#884), but it still SET one from the install environment (TOLLGATE_ADMIN_PASSWORD / TOLLGATE_PRIVATE_WIFI_PASSWORD) or a staged root-only file (/etc/tollgate/admin-password, /etc/tollgate/wifi-password). That is the same hazard one step over: an operator who pasted the run-book's EXAMPLE value verbatim ended up with root set to a string they never chose, with no link to it. Remove every credential write. 99-tollgate-setup: - drop TOLLGATE_ADMIN_PASSWORD / TOLLGATE_PRIVATE_WIFI_PASSWORD, ADMIN_PASSWORD_FILE / PRIVATE_WIFI_PASSWORD_FILE, operator_chosen_admin_password(), set_admin_password(), clear_admin_credential_provisional() and the generated private-WiFi PSK; - enforce_admin_credential now only ASCERTAINS: set/locked serve, empty/unknown refuse and drop the board, telling the operator to set one with `passwd root` (or LuCI) and re-run; - setup_private_network: preserve an existing passphrase, otherwise leave the private AP DISABLED (wired br-private is unaffected) until one is set; - keep reading the legacy admin-credential-provisional marker on upgrade. CLI: - `tollgate network private set-password` and the config applier now also set disabled=0 on the radio they write a passphrase onto, so setting one actually brings the (disabled-by-default) AP up. Tests: - admin-board-requires-credential: rewritten around the new invariant; ADD the regression that env AND file present leaves root byte-identical, passwd never invoked, the staged file unconsumed, and the board refused; - new tests/uci-defaults-private-credential_test.sh: no passphrase => no key, AP disabled; env/file ignored; existing key preserved+enabled; - new src/cli/network_setpassword_test.go: set-password and the applier enable. Docs: README + unreleased changelog fragments updated to match. Runs green in the module test tiers (only the pre-existing entry-ui-mapping_test.sh failures remain, identical to main). Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * chore: VERSION v0.6.0-rc2 (#903) Stamp the release candidate for the no-generated-credentials work. The feed's PKG_SOURCE_TAG is read from this file, so the built binary reports v0.6.0-rc2 to match the package version 0.6.0_rc2. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * rebase onto main (post-rc2 + #798 fence + credential series): builds: enforce the two-path doctrine — surface contract test, one-pass matrix split, rails (#713) Co-authored-by: Amperstrand <dev@amperstrand.no> * packaging: refuse to install when root has no password (preinst) + VERSION v0.6.0-rc3 (#907) The :8090 admin board's login is checked against root's /etc/shadow hash, and rpcd's session.login accepts ANY password while that hash is empty. The package never sets a credential, so a password-less install would leave an unauthenticatable board. Rather than install and then withhold the board, refuse BEFORE unpacking: the preinst exits non-zero on an empty root hash, so nothing is half-installed and the operator is told to `passwd root` and retry. - preinst (was a deliberate no-op): dependency-free (awk, no jq — the past jq-preinst-aborted-upgrades lesson), allows a locked root, bypasses on IPKG_INSTROOT (offline/image builds) and TOLLGATE_ALLOW_EMPTY_ROOT=1 (CI hatch). - new tests/packaging/preinst-refuses-empty-root_test.sh (9 checks). - VERSION -> v0.6.0-rc3. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * setup: default to a fixed TollGate credential instead of refusing (#913) * setup: default to a fixed TollGate credential instead of refusing The admin board's login is checked against root's /etc/shadow hash, and rpcd's session.login accepts ANY password while it is empty. rc3 refused to install in that state, but a failing pre-install is NON-FATAL under apk-tools 3 (the .apk landed anyway) and requiring `passwd root` before install burdened every operator. Replace the refusal with a FIXED default: 99-tollgate-setup sets root to TOLLGATE_DEFAULT_CREDENTIAL ("TollGate") when none exists, sets the private (management) WiFi key to the same value, marks the admin credential PROVISIONAL, and serves the board. The admin/config UI forces a change at first login (shipped separately, post-release). No install-time credential is accepted (TOLLGATE_ADMIN_PASSWORD / staged file stay ignored); an operator's own credential is preserved; the write still fails CLOSED if it cannot take. - preinst refusal -> no-op (the two lanes agree now). - new tests/packaging/admin-board-defaults-to-tollgate_test.sh (59 checks); preinst-refuses-empty-root -> preinst-never-refuses; the private-credential and configui-8090 suites updated to the default contract. - VERSION -> v0.6.0-rc4. * setup: mark the defaulted private-WiFi key provisional too setup_private_network writes /etc/tollgate/private-wifi-credential-provisional when it installs the fixed default passphrase (rather than one the operator chose), so the post-release admin/config UI can force a Wi-Fi passphrase change at first login alongside the admin one. The test asserts it is written only when the default is applied. --------- Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * docs(packaging): record the decommission plan for the module's own package build (#914) Freeze the intent, not yet the change: FreedomTechFeed/packages/net/tollgate-wrt/Makefile is the single canonical shipping package; this repo's packaging/Makefile is test/reproducibility only. packaging/README.md lists exactly what the feed consumes (packaging/files/) and the mechanical removal (Package/Build targets, build-ipk/local-build-ipk/build-env helpers, the build-package.yml publish jobs) to execute after the v0.6.0-rc4 release. Draft PR; merged post-release. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * test(harness): pin the env seams the setup script now resolves (release-check green) (#915) Three uci-defaults suites were red at release-check (they are NOT in the per-PR test.yml lane, so #913 merged with them red): - entry-ui-mapping (9): the suite's sed predated the TOLLGATE_ENTRY_UI_CONFIG env override, so the copied 99 read the HOST's live /etc/tollgate/config.json (the harness rot #899 describes). - admin-tls-identity (6) + trusted-entry-80 (1): the new private-WiFi provisional marker defaults to an absolute path the suites did not redirect. Harness-only: pin TOLLGATE_ENTRY_UI_CONFIG + ADMIN/PRIVATE_WIFI_PROVISIONAL_MARKER into each sandbox. NO change to 99-tollgate-setup (the risky half of #899). Sweep: all tests/packaging/* + tests/uci-defaults-* green. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * packaging: re-check the private subnet once the uplink is up (#926) setup_private_network derives network.private (lan/24 ±1) at uci-defaults time, before a fresh DHCP WAN necessarily has a lease, so an upstream that lands on the same /24 as the derived private network — the observed 192.168.2.1 against a 192.168.2.0/24 upstream — is missed. The router then answers for the upstream gateway's own address and DNS breaks on the deployed router. Add recheck_private_subnet (no-op unless the private /24 now overlaps a known upstream) and call it from the postinst AFTER `/etc/init.d/network restart`, reloading the network only when the private subnet had to move. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * fix(tollwallet): bound each mint registration so a slow mint cannot stall boot (#940) * fix(tollwallet): bound each mint registration so a slow mint cannot stall boot The wallet already registers only the mints the startup probe found reachable, but gonuts' AddMint does its own network I/O with NO http.Client timeout, so a mint that accepts the connection and never answers can stall the whole wallet construction — the module's own notes measure 113-347 s for 7 mints. That outlasts the installer's health window and made a healthy install report 'the merchant is degraded'. registerMint now gates AddMint behind a bounded GET /v1/keysets with OUR OWN client (8 s). A mint that does not answer is left UNREGISTERED rather than blocking boot: ensureMintRegistered retries it on first use and the reachable-set callbacks admit it when it recovers. Registration stays sequential, so gonuts is never called concurrently. Test: TestMintServesKeysets (serves keysets -> accepted; empty -> rejected; outruns the bound -> rejected). tollwallet + merchant modules green. * review: reword the bound's guarantee, state the residual, add a defer->recover test Addresses the independent review on the fork PR: - The comment no longer claims AddMint 'cannot hang'. The probe and AddMint are separate exchanges, so the bound makes a hang UNLIKELY rather than impossible; the real closure (a goroutine-with-timeout around AddMint, or registration off the boot path) is noted as a follow-up. - States the residual worst case explicitly: one registerMintKeysetTimeout per reachable mint, sequentially — 7 mints x 8 s = 56 s at boot — plus the new first-use latency for a deferred mint (a bounded <=8 s inline probe on the first payment routed to it; latency, not correctness). - Adds TestRegisterMintDefersThenRecovers: a mint DOWN at boot is left unregistered, and ensureMintRegistered (the path Receive takes) registers it once it recovers — pinning the integration claim through the real gonuts wallet, not just the probe unit. --------- Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * docs(operator-guide): fix the entry_ui=luci TLS-port attribution (#939) The SSL-command scope paragraph paired LuCI's :8443 with entry_ui=luci, inverted: under entry_ui=luci LuCI (uhttpd.main) owns the entry pair TLS port :443 and the board owns :8443. Both ports named in the old sentence belong to the entry_ui=board mapping. Ground truth: packaging/files/etc/uci-defaults/99-tollgate-setup port functions and docs/access-urls.md's mapping table. Found by the PR #744 review; one-line fix-forward. * docs: defer loopback rate-limit vs startup-gate follow-up (post-release) (#953) Record the root cause behind the 2026-10-16 'Setup failed' incident: the installer's own 3s poll exceeds RateLimitMiddleware's 10 req/min for 127.0.0.1, and the limiter runs before requireStarted, so the probe can get 429 {'error':'rate limit exceeded'} instead of the readable 503 {'code':'starting'} refusal. The installer side is worked around in rc33. The root-cause fix (exempt loopback from the limiter) is DEFERRED until after the current release — no new module RC in this line. Co-authored-by: c03rad0r <c03rad0r@users.noreply.github.com> * fix(upstream): select and blacklist upstreams by BSSID, not SSID (#910) * fix(upstream): select and blacklist upstreams by BSSID, not SSID One SSID can be served by several BSSIDs across different upstream networks — some routed to the internet, some captive or isolated. The manager keyed its blacklist by SSID and never pinned the scanned BSSID on the candidate, so a router that settled on a BSSID with no internet blacklisted the whole SSID, including any working sibling, and could never recover. The ICMP-only connectivity check could not tell "gateway but no internet" from "ok" either. - Blacklist by BSSID (falling back to the SSID only when it is unknown), skip candidates by BSSID, and blacklist the exact failed BSSID on an emergency switch. A sibling BSSID of the same SSID stays eligible. - Thread the candidate BSSID through SwitchUpstream and pin wireless.<iface>.bssid, clearing a stale pin when the candidate carries none. Manual CLI connect pins the strongest scanned BSSID for the SSID. - Replace the ICMP-only probe with a layered one — default route, DNS resolution, HTTP 204 captive-portal check — returning a reason (no-route/no-dns/captive/no-http). Used by the startup check, the main loop and post-switch verification. Tests: two-BSSID/one-SSID blacklist isolation, BSSID pin set/clear, and probe classification. make go-battery green across all 16 modules. * changelog: fragment for #910 (BSSID-aware upstream selection) --------- Co-authored-by: c03rad0r <c03rad0r@users.noreply.github.com> * ci(router-test): TBMG Trust Pipeline + bench lanes (dual twin) (#898) * ci(router-test): TBMG Trust Pipeline on the self-hosted runner + verdict comment Router-test is the TBMG Trust Pipeline entry point. Two changes: * Run the gateway dispatch on the self-hosted runner (role 86-tbmg-runner on hermes-nvme) over the private fips mesh with its dedicated per-machine key — no per-repo secrets and no metered LAN traffic. * Add a report job that posts ONE idempotent verdict comment per PR (marker <!-- tbmg-trust -->), so every PR shows whether it is ready to merge. Fork PRs (which must never run on a self-hosted runner) are reported as "not tested" instead of silently skipped. The security contract is preserved: PR -> isolated lab; bench only on main or an explicit dispatch behind bench-hardware. * docs(changelog): fragment for the TBMG Trust Pipeline (#898) * ci(router-test): dispatch the bench lanes on the gateway Add a gated `bench` job (non-PR, bench-hardware) that SSHes to the elected router-bench-gateway over fips and runs scripts/bench/bench-lanes.sh — the lane orchestrator (clean flash -> install -> smoke -> radios OFF, JSON summary). * ci(ngit): mirror the router-test trust pipeline into the ngit twin Land the trust pipeline in the ngit execution twin too (dual setup): same plan/router-test/bench jobs, ngit-safe (no GitHub `environment`/`vars`; the verdict is published by ngit-ci to Nostr, so the report job just summarises). --------- Co-authored-by: felixfelix-bot <felixfelix-bot@users.noreply.github.com> * feat(wireless): wire the vendor-IE price producer the scan boundary staged for (#959) * feat(wireless): wire the vendor-IE price producer the scan boundary staged for PricePerStep/StepSize were declared across four data models (NetworkInfo, Gateway, DiscoveryEntry, KnownTollGate) and had no producer: scanner.go hard-zeroed them with a 'STAGED (this PR)' comment, gated behind the VendorIEDiscovery flag whose consumer did not exist. Wire it: - scanner.go: capture the raw IE blob from the scan's 'IE: <hex>' line and route network assembly through a new buildNetworkInfo(), which calls the existing ParseVendorIEsFromScanData() to fill IsTollGate / RawIEs and the pricing fields. Remove the staged comment. - vendor_element_manager.go: add pricing TLVs (0x03 price_per_step, 0x04 step_size, both 2-byte big-endian) to the encoder and parser, so the IE can carry a price hint. - types.go: TollGateAdvertisement gains PricePerStep/StepSize. Authority is unchanged and now documented in-code: the IE is an UNSIGNED hint via OUI identification that selects a candidate for the signed kind-10021 probe on :2121, which remains the authority for price. A zero pricing value means 'not advertised here', never 'free'. Pricing TLVs are dropped (truncated=true) rather than overflowing the 255-byte element or pushing out the pubkey, because identification outranks the price hint. Tests (observed RED before the change, GREEN after): - TestParseIwinfoOutput_PopulatesVendorIEFields - TestParseIwinfoOutput_NonTollGateIEIgnored - TestParseIwinfoOutput_TollGateIEWithoutPricingTLVs - TestParseIwinfoOutput_NoIEMeansNotTollGate - TestParseIwinfoOutput_MultipleNetworksAndIEs - TestVendorIE_RoundTripPricing (+4 subtests) - TestVendorIE_PricingDroppedRatherThanOverflow Full wireless_gateway_manager suite green, including the five pre-existing vendor-IE tests (no regressions). * fix(wireless): parse the vendor element from the format the router actually emits Hardware verification on tollgate-A04E (2026-10-16) falsified the first implementation's assumption. Measured: - `iwinfo <dev> scan` prints NO information elements at all (grep -c 'IE:' returned 0), so it can never yield IsTollGate via the vendor element. It does report hidden APs, as `ESSID: "unknown"` — a literal placeholder. - `iw dev <dev> scan` is the ONLY element source, and it prints elements DECODED into named fields (SSID:, RSN:, HT capabilities:, ...), not as a hex blob. A vendor element appears as `Vendor specific: <colon-hex>`. - iw strips the element ID (0xDD) and the length byte from that body. Feeding the body straight to ParseVendorIEsFromScanData returns 0 elements: measured as 36 good bytes in, 0 advertisements out. The body must be re-framed with its 0xDD + length prefix. Change: - ParseIwinfoOutput now accepts BOTH real formats: iwinfo `Cell NN - Address:` records and iw `BSS <mac>(on ...)` records, per-record, so a stream carrying either parses. - SSID parsing handles both keys; RSN supplies encryption for iw output (it has no `Encryption:` line); `freq:` supplies the channel. - The vendor element is re-framed before parsing. - Placeholder SSIDs ("", "(hidden)", "unknown") are refused admission, so a literal cannot become a selectable upstream. Tests rewritten against the measured format: TestParseIWScanOutput_{PopulatesVendorIEFields,NonTollGateVendorIgnored, TollGateWithoutPricingTLVs,NoVendorElementMeansNotTollGate, RealHardwareHiddenSSID,MultipleNetworksAndVendorElements}. Three failed against the raw-body bug and pass with the re-frame; the full wireless_gateway_manager suite is green with no regressions. * fix(wireless): read VendorIEDiscovery from UpstreamWifi; scan via iw when enabled Build fix: the flag lives on the UpstreamWifi sub-struct, not on Config. With that wired, the scan path can finally populate the fields the vendor element carries. Two facts measured on tollgate-A04E (2026-10-16) drive the design: 1. `iwinfo <dev> scan` prints NO information elements, so it can never identify a TollGate by vendor element. It is kept because it reports hidden APs (as ESSID "unknown") that iw can omit. 2. `iw dev <dev> scan` is the only element source. When vendor-IE discovery is enabled, scanRadio runs it as well and mergeScanResults folds the two views by BSSID, letting the element-derived fields (IsTollGate, RawIEs, price, step) come from iw while iwinfo still contributes entries iw lacks. iw is best-effort: a failure or empty result keeps the iwinfo view rather than failing the scan. * test(wireless): cover mergeScanResults, scanRadioWithIW and the flag wiring Coverage on the newly added code was incomplete: mergeScanResults and scanRadioWithIW were both at 0%, and parseColonHex at 75%. The merge is the join that lets element-derived fields come from iw while iwinfo-only entries (notably hidden APs) survive, so an untested merge was the largest risk in this change. Added: - mergeScanResults: enriches an existing BSSID from the second view; keeps entries only one view saw; fills gaps (signal/encryption); is the identity when the second view is empty. - scanRadioWithIW: a failed iw invocation returns nil rather than propagating an error, so the caller keeps the iwinfo view. - NewScanner/SetVendorIEDiscovery wiring. Also moves the on-router demo to src/cmd/tg-vendor-ie-demo, so it compiles against the repo's own module graph (the nested scanner module is reached via the existing replace directives) instead of a hand-rolled go.mod. Coverage on the new surface is now: mergeScanResults 100%, appendScanEntry 100%, NewScanner 100%, SetVendorIEDiscovery 100%, ParseIwinfoOutput 98.6%, buildNetworkInfo 92.9%, scanRadioWithIW 70%, parseColonHex 75%. * docs(wireless): record the cross-build target defect found on hardware The demo binary was built with GOOS=linux GOARCH=arm64 but the router rejected it with 'syntax error: unexpected end of file'. Its ELF header read 02 00 3e 00 (e_machine 0x3e = x86-64) instead of 02 00 b7 00 (0xb7 = aarch64). The sha256 matched the local file exactly, so the transfer was correct — the build target was wrong. Adding CGO_ENABLED=0 produced a proper statically linked aarch64 binary. The check that would have caught it in one line, now recorded for reuse: head -c 20 <binary> | od -An -tx1 | tail -1 # want '02 00 b7 00' for aarch64 Size and sha256 are NOT evidence of the right architecture; both matched while the binary was unrunnable on the target. * feat(demo): add --file mode and record the on-router demo evidence The demo could only run 'iw dev <dev> scan' live. Where no neighbour running this build exists (as on tollgate-A04E), that can only ever show the ABSENCE of a vendor element, never the positive identification path. Adding '--file <path>' lets the parser be run against a saved scan, so a synthetic element can be injected into REAL captured router output. Evidence from the live MT6000 (GL.iNet GL-MT6000, OpenWrt 24.10.2, aarch64_cortex-a53), 2026-10-16: A. live scan: iw dev phy0-ap0 scan: 28736 bytes, err=<nil> Vendor specific: lines present = 0 parsed networks = 12 identified TollGates = 0 (correct: no neighbour runs this build) B. real scan + one injected TollGate element: read /tmp/injected-scan.txt: 35168 bytes Vendor specific: lines present = 1 parsed networks = 16 TOLLGATE bssid=b2:36:4a:2a:03:32 ssid="StarGate" signal=0 price_per_step=7 step_size=1000 rawIE_bytes=38 identified TollGates = 1 -> the parser located the element inside genuine scan data and recovered the encoded price/step exactly. The demo binary was cross-built with CGO_ENABLED=0 and its ELF machine field verified as 0xb7 (aarch64) BEFORE transfer — the check whose absence produced the unrunnable x86-64 binary recorded in the previous commit. * fix(wireless): emit pricing before the pubkey so an oversized pubkey fails loudly Defect found by a cold cross-family review (deepseek-flash, PR #959, 2026-10-16). With the original ordering a pubkey could pass the fit check and land the body at 253-255 bytes; the pricing TLVs were then appended AFTER it, the 4-byte guard fired, and the pricing was silently dropped while the element still reported success. A router whose pubkey drifted large would appear to advertise no price — indistinguishable from a router that advertises none. Fix: emit pricing BEFORE the pubkey. An oversized pubkey now fails the existing loud error path ('vendor IE body too long') instead of silently losing the price. The length octet is also clamped explicitly with truncated=true, so a wrap can never produce a malformed element silently. Tests (observed RED against the original ordering, GREEN after): - TestEncode_PricingNotDroppedByPubkey (fails: 'pubkey 239 bytes cannot fit') - TestEncode_PricingSurvivesWithMaxFitPubkey (loud error for oversized pubkey) - TestEncode_LengthOctetNeverWraps (octet == body length) Encoder coverage 87.2%. Full wireless_gateway_manager suite green; go vet clean. MOUNTED BINARY VERIFIED: demo rebuilt for the router and its ELF machine field checked as 02 00 b7 00 (aarch64) before any transfer. Credit: this defect was surfaced by the cross-family lane, not by me. * refactor(wireless): rename the scan-side price hint so it cannot be confused with the authoritative one Follow-up from a cold cross-…
… (#1104) * docs: environment truth and drift doctrine — one declaration, glob discovery, fenced duplication, the two-era policy (#796) (#799) The gonuts pin (#791), the port table (#746), and the #796 audit all have one shape: a fact written in more than one place. AGENTS.md now states the rules that prevent the class instead of enumerating the values that drifted: one declaration per fact, discovery by glob (never a count in prose), manifest truth for externals, behavioral fences with planted-drift verdicts, generators that write every copy. Plus the bridge vocabulary and its single Go declaration site, the nft-fragment port-fencing rule, the apk/ipk era probe (never release-string parsing), the 25.12-primary / 24.10.8-frozen support policy, and the cross-repo-halves contract. Co-authored-by: pr-train-lane <lane@opencode> * fix(packaging): ship the GPL license text in BOTH release lanes (#751) (#787) The SDK-free .ipk lanes installed the GPL-3.0 text at /usr/share/doc/tollgate-wrt/LICENSE while the SDK .apk lane (packaging/Makefile) was metadata-only — PKG_LICENSE_FILES feeds the feed's license manifest, not the payload — so the same version shipped different contents per format, and this ~35 KB file was the lone content delta blocking byte-equivalence between the lanes. Decision, on compliance grounds: both formats ship the text. GPL-3.0 distribution reaches the recipient together with the binary — the conservative reading the issue itself names — and the metadata (PKG_LICENSE) and the shipped text are complementary, never either/or. The cost is ~35 KB on any router. - packaging/Makefile installs $(PKG_BUILD_DIR)/LICENSE to /usr/share/doc/$(PKG_NAME)/LICENSE in Package/install — the same payload path the .ipk lanes use. - Build/Prepare now REQUIRES the LICENSE (the copy was best-effort `|| true`): a GPL build that cannot find its license fails loudly instead of silently shipping a binary without it. - assert-artifact-contents.sh (run on every lane's real artifact in CI) hard-fails any package — .ipk or .apk — whose payload lacks the file, and reports its byte size. - tests/packaging/license-both-lanes_test.sh pins every staging surface (Makefile install + hard requirement, local-build-ipk, the CI ipk lane), path agreement across lanes, the metadata staying intact, and the artifact gate staying hard; wired into test.yml. - The two artifact-building fixture suites stage the real LICENSE so their synthetic .ipks match a real one (the new gate caught them immediately — the invariant works). Verified with a synthetic artifact both ways: with the license the harness PASSes (35593 bytes), without it the hard FAIL fires (exit 1). Packaging suite green except apk-install-resolution (docker-gated, fails identically on pristine main). Fixes #751 * feat(packaging): let the operator choose the admin password at install time (#827) A router whose root password was unset received a GENERATED credential, printed exactly once. Its only other copy went to /tmp/tollgate-setup.log -- tmpfs, and truncated at the start of every full-setup run -- so an install or upgrade erased the record. "Shown once" is only safe if there is a way not to need it. Two changes: - The install honours TOLLGATE_ADMIN_PASSWORD. uci-defaults run from the package manager with no controlling terminal, so the environment is where a choice can arrive; this is also the mechanism a fleet or CI provisioning script uses. The value is applied to root, never echoed to the output or the setup log, and replaces an existing credential when supplied. If it does not take, the gate fails closed and drops the admin listeners rather than leaving the board on the old credential. - A credential this package GENERATES is now marked provisional by /etc/tollgate/admin-credential-provisional (no secret in it -- the file is world-readable). The marker is (a) the signal the config UI reads to force a password change at first login and (b) the reason a later setup run re-states the recovery path instead of implying the operator chose it. A locked root account and an unreadable shadow stay exempt: neither is a state this package should decide to unlock. Tests extend tests/packaging/admin-board-requires-credential_test.sh (its own harness, per repo doctrine). RED observed first: 5 failures against the pre-change script with the 77 existing assertions passing. Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * fix(packaging): declare the CA bundle and state the manual-install trust chain (#828) * fix(packaging): declare the CA bundle and state the manual-install trust chain Installing the shipped package by hand on a GL-MT3000 (OpenWrt 25.12.5) failed at the first HTTPS fetch: wget AND apk update both died with "SSL verify error: unknown error". The image had no CA store, so no TLS connection could be verified. This module's job is money. It talks to Cashu mints and Lightning over TLS, the Go binary uses the system root pool (no InsecureSkipVerify, no bundled pool), so without a CA bundle every mint and Lightning call fails verification -- a gateway that cannot verify TLS cannot take a payment. The installer used to paper over this by fixing the box first; installing the package alone has to work, so ca-bundle is now a declared dependency rather than an assumption. The docs also gain the chain that makes an unsigned artifact safe to install by hand, because "install it yourself" is only honest if the verification is stated: the release's SHA256SUMS is signed (SSHSIG/ed25519, public half at .github/release-keys/release-signing.pub), so verify the signature FIRST, then check the artifact hash, then install. The artifact carries no apk-level signature, which is exactly why the manifest must be verified before the bytes are trusted. On a stock router ssh-keygen is absent (dropbear ships, not OpenSSH), so the manifest is verified on a trusted machine and the hash checked on the router. tests/packaging/apk-install-resolution_test.sh gains source-level assertions for both declarations, run BEFORE its docker gate so a machine without docker still checks them, and its header's honesty list now says what it does and does not prove about trust. RED observed first (both source files reverted, test kept): the CA assertion and two of the three documented-chain assertions fail. GREEN after: all four pass and the docker closure lane still resolves (12.5 MiB, 156 packages). * docs(packaging): point manifest verification at allowed_signers, not the bare key The README named .github/release-keys/release-signing.pub as the key to use, while the command it printed passed -f allowed_signers. Those are different files in different formats: release-signing.pub is a bare ed25519 public key, allowed_signers is the same key prefixed with the principal the -I identity is matched against. Following the prose sent the reader to a file the verifier rejects. Name the file the verifier consumes and fetch it explicitly. The lane-pinning needle added here is a PRESENCE pin only: allowed_signers already appeared in the printed command, so it does not go RED against the pre-fix README. This fixes documentation accuracy, not behaviour. * fix(packaging): declare the CA bundle on the .ipk lanes too The CA-bundle dependency was declared only in the SDK recipe (packaging/Makefile). The two .ipk build lanes -- packaging/local-build-ipk.sh and .github/workflows/build-package.yml -- still stamped `Depends: libc, nodogsplash, jq`, and scripts/ngit-gen-shards.py propagated the same line into every generated .ngit shard, so the OpenWrt <=24.10 opkg lane installed a module with no CA store and the same class of TLS failure the apk lane hit was still reachable there. Declare ca-bundle in the recipe, the GitHub lane and the shard generator, and regenerate the shards (scripts/ngit-gen-shards.py --check is clean). The sibling contract test now expects the exact new Depends value rather than a prefix of it, so a future drop of the bundle is a failure and not a still-matching prefix. This is the dependency half of the same defect class: it fixes an install on a box that can still reach the feed. It does not, and cannot, bootstrap a box with no trust anchors -- that path is stated in the README. Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * docs(packaging): state the honest offline bootstrap and pin the key file The README claimed the +ca-bundle dependency makes a hand install work. It does not close the failure the change reports: on the box the defect was found on, apk update itself died with an SSL verify error, and apk must fetch ca-bundle over the same TLS it cannot verify. The dependency is correct for a box that CAN reach the feed; it cannot repair one with no trust anchors. Say so. The dependency fixes a feed-reachable box (the closure is resolved and ca-bundle comes with it). For a box with no trust anchors the README now gives the offline path instead: bring the package files over by other means (scp) and install them locally in one apk call under a single explicit trust override, with the opkg equivalent for the <=24.10 lane and the clock caveat. Verified in a container (openwrt/rootfs 25.12, CA store removed): a local `apk add --allow-untrusted ./pkg.apk` completes even while every HTTPS fetch fails with an SSL verify error, because the local file install does not need the fetch to succeed. Two accuracy fixes in the same section: pin the fetched key file to a commit SHA instead of the moving master branch, and name the real fetchers on a stock router (wget or uclient-fetch, not curl) -- curl is as absent as ssh-keygen, so the previous note listed only half of what is missing. Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * docs(changelog): record installer-free-trust as added, without the overclaim The fragment narrated added behaviour and docs, so .changed was the wrong suffix; rename it to .added (the repo convention, see vm-campaign.added.md and 679-changelog-fragments.added.md) and reword it to match what the change now does: ca-bundle is a declared dependency on every build lane, which fixes an install on a box that can reach the feed, and the README states the offline path for a box that cannot -- rather than implying the dependency alone bootstraps an anchor-less box. Also drop the release-signing.pub reference: the verifier consumes the allowed-signers file, not the bare key beside it. That mismatch was fixed once already in 084b1179 and the fragment reintroduced it. Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * test(packaging): assert the CA dependency from build metadata, not grepped text apk-install-resolution_test.sh "proved" the CA-bundle declaration and the documented trust chain by grepping README.md and packaging/Makefile for literal strings. That was tautological, and the three failure modes are reproducible against the old test: - a README that tells the reader to never use the override still passes (the literal string is still present); - a dependency living only inside a `# ... +ca-bundle` comment still passes; - a formatting-only Makefile edit (extra spaces) FAILS, because the regex pins the source text rather than the declaration. Replace both greps with assertions over build metadata: (1) apk/SDK lane: expand packaging/Makefile with `make -pn` and require the bundle in the DEPENDS of the package definition make reads. Comment lines are dropped before matching, so a dependency that exists only in a comment cannot satisfy it, and whitespace no longer matters; (2) opkg/.ipk lane: build a real .ipk from the recipe's DEPENDS with packaging/build-ipk.sh and assert the `Depends:` field of the control file opkg consumes -- the byte-level artefact, which raw text cannot fake. Both run before the docker gate and both fail closed. The header now states exactly what each check proves and what it does not (the SDK expansion proves the recipe declares it, not that a build emitted it; the artifact check is the load-bearing one). RED observed first, then GREEN: - dependency moved into a comment in packaging/Makefile -> check (1) FAIL, exit 1 (the old test passed this case); - ca-bundle removed from packaging/local-build-ipk.sh -> check (2) FAIL, exit 1; - extra whitespace only in packaging/Makefile -> both checks PASS (the old test failed this case); - old test vs a README that says "do NOT ever use this override" -> old test PASS, exit 0 (the tautology, reproduced); - all 12 tests/packaging suites pass with the new test in place. Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * docs(packaging): verify the trust anchor before installing it locally * docs(packaging): qualify the feed DEPENDS citation (ca-bundle lands with packages#57) * docs(changelog): lead with the declared dependency, keep the GL-MT3000 as evidence * docs(readme): fail-closed on every staged package file, not just the first --------- Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> Co-authored-by: Felix <felix@opentollgate.org> Co-authored-by: Felix <felix@freedomtechfeed.org> * fix(packaging): run the fail-closed admin gate LAST so the credential exists first (#838) * fix(packaging): run the fail-closed admin gate LAST so the credential exists first The admin board's setup script (staged from the pinned portal tree) is fail closed: while root's /etc/shadow hash is empty it DELETES uhttpd.admin's listeners, on the principle that the board must be unreachable rather than merely unhelpful. 99-tollgate-setup is what CREATES that credential. The recipes ran the uci-defaults in numeric order (90, 92, 99), so on a fresh hand-install the gate ran BEFORE the credential existed and killed the admin board with nothing to restore it. Stage the portal's 92-tollgate-admin-setup as 999-tollgate-admin-setup instead, so it sorts after 99-tollgate-setup in the boot glob, and run 90, 99, 999 on both install recipes (the packaging/Makefile postinst loop and packaging/postinst). The numeric prefix IS the boot order, because /etc/init.d/boot applies /etc/uci-defaults/* in one collated glob, so an install and the next boot now agree on the last writer. Also resolve the gate's operator-facing messages from the same entry_ui mapping the listener drop uses (:8080/:443 in board mode, :8090/:8443 in luci mode) instead of a hardcoded :8090, so the refusal and the board's own script name the same URL for one board. tests/packaging/uci-defaults-run-order_test.sh pins the gate-last contract - the credential runs before the gate, the gate runs last, and the gate's installed filename sorts after the credential script's - judged by one predicate, with the pre-change numeric order and a recipe-only reorder as negative controls and the gate-last order as the positive control. * test(packaging): keep the admin-board suite off the build host The suite exercised the shipped driver in a sandbox but left three ABSOLUTE paths pointed at the real filesystem: * /etc/tollgate/entry-ui-mapping -- READ. A marker left on the build host leaks in and flips entry_ui, and with it the board's pair. Measured 2026-10-09: a stray marker containing `board` turned this suite's expected ':8090/:8443' refusal into ':8080/:443' and failed it on an otherwise green tree (the only net-new failure in this branch). * /etc/tollgate/config.json -- READ, and not overridable at all. * /etc/tollgate/admin-credential-provisional -- WRITTEN. An un-sandboxed run deposits a generated credential in the build host's /etc/tollgate. Pin all three inside $TMP, and make ENTRY_UI_CONFIG overridable via TOLLGATE_ENTRY_UI_CONFIG -- the same `${VAR:-default}` seam the adjacent ENTRY_UI_MARKER and ENTRY_UI_JQ_BIN already use, so the harness needs no new mechanism. Verified, packaging tier, same loop on both trees: origin/main (22f111ab): 267 passed / 33 failed this branch : 277 passed / 9 failed The 9 remaining failures are in configui-8090-single-owner_test.sh and are BYTE-IDENTICAL to main's (diffed the failing assertion text) -- pre-existing double-ownership of :8090, untouched here. --------- Co-authored-by: Felix <felix@opentollgate.org> Co-authored-by: Felix <felix@freedomtechfeed.org> * docs(agents): firewall/naming rules of engagement, verification-venue map, changelog fragment convention (#800) * docs(agents): firewall/naming rules of engagement, verification-venue map, changelog fragment convention Three additions distilled from the 2026-10-09 stop-ship reviews and bench sessions (#754/#755/#756/#757, PR #782's first design): - Firewall, nftables and topology-naming rules of engagement: accept is not final across base chains (a green render-level contract suite hid a dead design in #782), no runtime nft inserts, defines-not-literals including generated fragments (34- generator is the open instance; #601's planned 33- is pre-committed), same-priority cross-family ordering fragility, fw4-reload NDS-state-wipe disclosure duty, the three-namespaces 'lan' hazard + netifd wireless regen, and the Go-side interface-literal grep list. - Where each class of verification lives: contract suites (mutation-check duty; a cited gate must test the cited thing), per-era bench lanes with an image-doctor preflight, PRTA as the home for topology/journey scenarios, cloud-lab crash lanes, the physical tier. - Changelog requirements rewritten to the changelog.d fragment convention, fixing the AGENTS.md / changelog.d-README split-brain that mis-led three PRs in one week. * docs(changelog): fragment for the AGENTS.md knowledge rules (#800) --------- Co-authored-by: Amperstrand <dev@amperstrand.no> Co-authored-by: Amperstrand <amperstrand@users.noreply.github.com> * test(merchant): late-receive outcome selection raced the owed-grant ERROR line — pick the COMPLETED record by name (#681 red herring) (#718) * test(merchant): duplicate-guard harness needs an ndsctl on PATH — install the renewal fake (#822) (#835) * test(merchant): FiredOnce pinned the opposite of the wired contract — assert fire-exactly-once deterministically (#732) (#836) * test(contract): SSID matcher fence — pinned acceptance table for hasTollGateSSID (#618/#706 classes) (#839) The reader twin of check-ssid-format.sh: what the WRITER may emit is pinned there, what the MATCHER must accept is pinned here. One fixture table (tests/contract/ssid-naming-fixtures.txt) drives both the Go unit test and tests/contract/check-ssid-naming.sh (structural: table covers every required class and the Go pin is wired; behavioural: the real go test runs over the table). Classes: both brands bare (#618 — the matcher was brand-blind and a whitelabel upstream was invisible), case- insensitive (lowercase installer-era SSIDs still in the fleet), single-'!'-decorated with double-'!' rejected (#706). Lands #706's reader-side tolerance (trim at most one '!') so the table is green on merge; #706 then only needs its writer/installer half. The whitelabel fixtures carry the brand literal in the table's file — the SSID contract's allowlisted home in the rebrand gutter (same exemption as check-ssid-format.sh); Go source keeps assembling it from non-contiguous bytes. Negative control: a planted contradictory fixture line turns the fence red (behavioural layer), removed again. Refs #767 Co-authored-by: Amperstrand <dev@amperstrand.no> Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * test(merchant): closeGateCleanup clears an abandoned close streak — the streak outlived the test that spent it (#841) (#842) * fix(wallet,merchant): correlate NUT-07 answers by value; fsync journal directories after rename (#834) (#876) * fix(wallet,merchant): correlate NUT-07 answers by value; fsync journal directories after rename (#834) Two audit-hardening halves of #834: - CheckTokenSpent counted answered states but never correlated each response Y back to a requested one. It now correlates by value: an answer about a Y we did not ask about, or a duplicate answer padding a short reply, is an error instead of a silent verdict — the #502 pending-intent reconciliation (abandoned vs owed) decides on this evidence, and NUT-07 requires states to match the request. - the three money journals (receive intents, owed grants, quote store) fsynced the temp file but not the directory after rename; on ext4-class filesystems the rename itself is only durable after the directory entry is synced, so a power cut could drop the newest journal write whose contents were already fsynced. syncParentDir closes the gap after every rename, surfacing a failed dir fsync as a save error (the record must be treated as not-yet-durable, exactly like a failed temp Sync). * changelog: fragment for #840 (NUT-07 value correlation + journal dir fsyncs) * test(tollwallet): pin the NUT-07 shortfall refusal — direct coverage for the answered-N-of-M branch --------- Co-authored-by: Amperstrand <dev@amperstrand.no> Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * fix(deps): the gonuts pin is one version, everywhere — manifest truth, glob fence, mechanical bump (#791) (#798) The wallet fork's pin drifted three ways in one release week: a hand bump moved four carriers and left a fifth (scripts/token-recovery) three minors behind; two open PRs pinned the same lines to a tag and a pseudo-version; a newer fork tag carrying an open-P2 fix went unnoticed. - packaging/build-inputs.json names the pin (.gonuts.version) as the single source of truth, beside the go and portal pins. - tests/contract/check-gonuts-pin.py discovers carriers by glob (both go.mod require shapes — the single-line form hid the token-recovery stale pin from the checker's first draft) and refuses any disagreement, offline, in pre-commit and both CI twins; --latest (release-check only) refuses a release while the fork has a newer stable tag. - tests/contract/gonuts-pin_test.sh: 7 planted-drift verdicts. - scripts/bump-gonuts.sh rewrites manifest and every carrier together. - scripts/token-recovery raised v0.10.0 -> v0.13.0 (the fence's first catch; builds and vets clean against the new pin). - AGENTS.md states the rule and the check, never a carrier count. Co-authored-by: pr-train-lane <lane@opencode> * fix(packaging): allow pre-auth DNS on UDP, not only TCP — users_to_router udp/53 (#749) (#769) The nodogsplash pre-auth allow list carried tcp/53 (the feed default) but not udp/53, and stub resolvers query UDP first: the ndsRTR chain dropped the query, the client burned a ~5s timeout per attempt before any TCP fallback, and hostname resolution from a captive client was dead in practice — while the portal page itself is usually reached by name (local-lab campaign behind PRTA PR #101). assert_nodogsplash_allow_entries now asserts BOTH protocols, with a new protocol-aware guard (uci_list_has_entry): uci_list_has_port matches a port on either protocol, so guarding udp/53 with it would see the feed's tcp/53 entry and skip the add. tcp/53 is asserted alongside (not left to the feed default) because the writer's contract is that a stale or absent list is repaired on every setup path. tests/uci-defaults-dns-preauth_test.sh pins the matrix against the shipped writer via the fake-uci harness: feed-default list gains udp/53 without duplicating tcp/53, a stale list is repaired on both protocols, the guard is proven protocol-aware (udp present + tcp missing still adds tcp), reruns add nothing, and uci's quoted single-line rendering is handled. The same-version allowlist and convergence suites carry the DNS entries through their full-script runs; CI runs the new suite on every push. Fixes #749 * chore(packaging): pin the portal at the merged network-settings revision (#766) The portal pin moves to 6c25834c (OpenTollGate/tollgate-captive-portal-site#66, merged) — the board half of the operator settings whose module half landed in #604: the Settings cards for the private network's credentials and the administration scope, and the WiFi page's private-radio edit routed through `tollgate config_set` with the section-name contract pinned (both `wireless.private_radio0/1` names and the structural AP-on-`private` signal, read in the netifd `config`-nested shape and the flat fallback) and the half-applied save named rather than swallowed. The gate trail is on the PR: rounds 1-3 CHANGES-REQUESTED, round 4 flagged the structural net reading the wrong shape, round 5 delta verification APPROVED. Evidence: 23/23 Playwright specs across the three admin suites, 4/4 unit tests, tsc clean, packaging guards and the anti-leak gate green. The .secrets.baseline entry for the pin line moves with it, per the baseline's documented purpose of recording reviewed pins: a pin whose hash is not recorded is (correctly) a new alert. As with #714, the pin is the atomicity boundary: the module's rc1 changelog entry already says these settings are settable "on the board" — only a release built after this commit actually carries the board half. Co-authored-by: Amperstrand <amperstrand@localhost> * fix(cloud-lab): Dockerfile.client's GO_VERSION must be a global ARG — the client image cannot build from scratch (#844) * fix(cloud-lab): Dockerfile.client's GO_VERSION must be a GLOBAL arg — the client image cannot build from scratch Dockerfile spec: only args declared before the FIRST FROM are visible to FROM lines. Dockerfile.client declared ARG GO_VERSION=1.26.8 inside the rust-builder stage (after FROM rust:1.88-bookworm) and then used it in the go-builder stage's FROM — where it resolves empty, so every from-scratch build of the client/killer image fails: failed to solve: failed to parse stage name "golang:-bookworm": invalid reference format Introduced by #687's toolchain-parity sweep, which added the ARG to both cloud-lab Dockerfiles — correctly placed in Dockerfile.tollgate (before its first FROM), mid-file in Dockerfile.client. Nothing caught it because the org CI that builds these images has been dark and local lanes ran on cached images. Move the ARG above the first FROM. Verified on the rig (ai-legion, docker 29.1.3/compose 2.40): with this exact change the killer image builds and the #497 crash-injection lane runs to completion on #793's head — pending swap recovered, post-recovery payment 200 (transcript: /root/src/rel-793/transcript-stock.txt on ai-legion). * docs(changelog): fragment for the cloud-lab client global-ARG fix * chore: re-sync the PR head after auto-merge churn (no content change) --------- Co-authored-by: Amperstrand <amperstrand@users.noreply.github.com> Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * fix(merchant): receiveTimeout as per-merchant seam — the package var raced leaked PurchaseSession readers (#821) (#837) * fix(setup): never print generated credentials; add TOLLGATE_PRIVATE_WIFI_PASSWORD (#850) * fix(setup): never print the generated admin/WiFi credentials; add TOLLGATE_PRIVATE_WIFI_PASSWORD * docs(changelog): fragment for the no-print credential change * fix(wireless_gateway_manager): upstream connect refreshes credentials and clears competing STAs (#817) (#848) * fix(wireless_gateway_manager): upstream connect refreshes credentials and clears competing STAs (#817) Two defects chained into the desk-run failure: Reuse: FindOrCreateSTAForSSID returned a disabled STA section for the SSID without writing the new passphrase — a rotated password was dropped, the join failed WRONG_KEY, and the flow reported a DHCP wait. The reuse path now rewrites encryption, key (deleted for open networks) and network=wwan before returning. Exclusivity: mt76 exposes ONE STA netdev per radio, so any other enabled STA section on the target radio keeps the netdev and its (possibly stale or foreign — the desk run's tollgate_uplink) key is what wpa_supplicant joins with while the fresh candidate never gets an interface. SwitchUpstream now disables every enabled STA on the candidate radio, not just the section GetActiveSTA named; the auto-switcher in upstream_manager inherits the same guarantee. Tests drive the real Connector through an in-memory uci fake (the mock-level tests asserted canned returns and never touched this logic); all three new tests are planted-drift-verified to fail on the pre-fix code. * fix(wireless_gateway_manager): address cross-family review — log the stale-key delete, note the revert side effect Two non-blocking findings from #848's review: the open-network transition's key delete now logs failures at debug (the delete is the safeguard itself; an error was silent), and SwitchUpstream's comment states that a reverted switch deliberately leaves competing STAs disabled — the stale key must not regain the netdev — pinned by the revert test. * fix(wireless_gateway_manager): map bare 'WPA PSK' scans to psk-mixed iwinfo reports 'WPA PSK (CCMP)' both for genuine WPA1 networks and for mixed/WPA2-preferring APs whose beacon leads with the WPA1 IE — measured on the lab bench: such an AP rejects a psk section's WPA1 auth and only completes WPA2, so the connect flow wrote a section that could never handshake and reported a DHCP wait (#817's second root cause, same operator-visible symptom as the garbage-key defect). psk-mixed negotiates either way; regression test pins the measured AP shapes. * test(wireless_gateway_manager): pin the psk-mixed mapping in the legacy DetectEncryption table The pre-existing table asserted the old WPA1 mapping for 'WPA PSK (TKIP)'; 0e3da8f deliberately maps that shape to psk-mixed (iwinfo prints the same string for genuine WPA1 and for WPA2-preferring APs whose beacon leads with the WPA1 IE — measured on the bench; psk-mixed negotiates either). Caught by the full go-battery, which the -run filter had skipped via a non-matching test name. --------- Co-authored-by: Amperstrand <dev@amperstrand.no> * fix(setup): never generate a credential the operator cannot see (M1) (#896) * fix(setup): never generate a credential the operator cannot see M1: when root has no password and TOLLGATE_ADMIN_PASSWORD is unset, the package now generates NOTHING and refuses to serve the admin board, instead of creating a provisional credential. A generated-but-withheld password is exactly how routers got locked out (the installer deferred one credential while this package generated another; the operator held neither). The admin password must be provided: as TOLLGATE_ADMIN_PASSWORD at install time, or with `passwd root`, then the gate is re-run. The generate_admin_password/mark_admin_credential_provisional helpers are removed; a provisional marker carried over from an older install is still honoured so an upgraded router keeps telling the operator the credential was generated. * changelog: fragment for #884; fix #850 fragment format The #850 fragment started with a heading instead of a bullet, so changelog-assemble.py check failed on main. Reformatted it as a bullet. --------- Co-authored-by: Your Name <you@example.com> * setup: read the operator credential from a root-only file too (#897) apk-tools 3 (OpenWrt >= 25.12) does NOT forward the caller's environment to package scriptlets, so `TOLLGATE_ADMIN_PASSWORD='…' apk add` never reaches 99-tollgate-setup there: the module saw the env empty and refused the admin board (fail closed, no lockout, but no board). Proven on a GL-MT3000 running vanilla 25.12.5 / apk-tools 3.0.5. operator_chosen_admin_password now falls back to a root-only file (/etc/tollgate/admin-password); the private-WiFi passphrase likewise falls back to /etc/tollgate/wifi-password. Both are consumed (removed) once applied. The env still wins when present, so opkg (where the env IS forwarded) is unchanged. tests/packaging/admin-board-requires-credential_test.sh: +2 cases (staged file applies when the env is empty; env wins over the file). 80 passed, 0 failed. Co-authored-by: Your Name <you@example.com> * setup: never SET a credential — admin or private-WiFi (#884 follow-up) (#902) The package already stopped GENERATING an admin credential (#884), but it still SET one from the install environment (TOLLGATE_ADMIN_PASSWORD / TOLLGATE_PRIVATE_WIFI_PASSWORD) or a staged root-only file (/etc/tollgate/admin-password, /etc/tollgate/wifi-password). That is the same hazard one step over: an operator who pasted the run-book's EXAMPLE value verbatim ended up with root set to a string they never chose, with no link to it. Remove every credential write. 99-tollgate-setup: - drop TOLLGATE_ADMIN_PASSWORD / TOLLGATE_PRIVATE_WIFI_PASSWORD, ADMIN_PASSWORD_FILE / PRIVATE_WIFI_PASSWORD_FILE, operator_chosen_admin_password(), set_admin_password(), clear_admin_credential_provisional() and the generated private-WiFi PSK; - enforce_admin_credential now only ASCERTAINS: set/locked serve, empty/unknown refuse and drop the board, telling the operator to set one with `passwd root` (or LuCI) and re-run; - setup_private_network: preserve an existing passphrase, otherwise leave the private AP DISABLED (wired br-private is unaffected) until one is set; - keep reading the legacy admin-credential-provisional marker on upgrade. CLI: - `tollgate network private set-password` and the config applier now also set disabled=0 on the radio they write a passphrase onto, so setting one actually brings the (disabled-by-default) AP up. Tests: - admin-board-requires-credential: rewritten around the new invariant; ADD the regression that env AND file present leaves root byte-identical, passwd never invoked, the staged file unconsumed, and the board refused; - new tests/uci-defaults-private-credential_test.sh: no passphrase => no key, AP disabled; env/file ignored; existing key preserved+enabled; - new src/cli/network_setpassword_test.go: set-password and the applier enable. Docs: README + unreleased changelog fragments updated to match. Runs green in the module test tiers (only the pre-existing entry-ui-mapping_test.sh failures remain, identical to main). Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * chore: VERSION v0.6.0-rc2 (#903) Stamp the release candidate for the no-generated-credentials work. The feed's PKG_SOURCE_TAG is read from this file, so the built binary reports v0.6.0-rc2 to match the package version 0.6.0_rc2. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * rebase onto main (post-rc2 + #798 fence + credential series): builds: enforce the two-path doctrine — surface contract test, one-pass matrix split, rails (#713) Co-authored-by: Amperstrand <dev@amperstrand.no> * packaging: refuse to install when root has no password (preinst) + VERSION v0.6.0-rc3 (#907) The :8090 admin board's login is checked against root's /etc/shadow hash, and rpcd's session.login accepts ANY password while that hash is empty. The package never sets a credential, so a password-less install would leave an unauthenticatable board. Rather than install and then withhold the board, refuse BEFORE unpacking: the preinst exits non-zero on an empty root hash, so nothing is half-installed and the operator is told to `passwd root` and retry. - preinst (was a deliberate no-op): dependency-free (awk, no jq — the past jq-preinst-aborted-upgrades lesson), allows a locked root, bypasses on IPKG_INSTROOT (offline/image builds) and TOLLGATE_ALLOW_EMPTY_ROOT=1 (CI hatch). - new tests/packaging/preinst-refuses-empty-root_test.sh (9 checks). - VERSION -> v0.6.0-rc3. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * setup: default to a fixed TollGate credential instead of refusing (#913) * setup: default to a fixed TollGate credential instead of refusing The admin board's login is checked against root's /etc/shadow hash, and rpcd's session.login accepts ANY password while it is empty. rc3 refused to install in that state, but a failing pre-install is NON-FATAL under apk-tools 3 (the .apk landed anyway) and requiring `passwd root` before install burdened every operator. Replace the refusal with a FIXED default: 99-tollgate-setup sets root to TOLLGATE_DEFAULT_CREDENTIAL ("TollGate") when none exists, sets the private (management) WiFi key to the same value, marks the admin credential PROVISIONAL, and serves the board. The admin/config UI forces a change at first login (shipped separately, post-release). No install-time credential is accepted (TOLLGATE_ADMIN_PASSWORD / staged file stay ignored); an operator's own credential is preserved; the write still fails CLOSED if it cannot take. - preinst refusal -> no-op (the two lanes agree now). - new tests/packaging/admin-board-defaults-to-tollgate_test.sh (59 checks); preinst-refuses-empty-root -> preinst-never-refuses; the private-credential and configui-8090 suites updated to the default contract. - VERSION -> v0.6.0-rc4. * setup: mark the defaulted private-WiFi key provisional too setup_private_network writes /etc/tollgate/private-wifi-credential-provisional when it installs the fixed default passphrase (rather than one the operator chose), so the post-release admin/config UI can force a Wi-Fi passphrase change at first login alongside the admin one. The test asserts it is written only when the default is applied. --------- Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * docs(packaging): record the decommission plan for the module's own package build (#914) Freeze the intent, not yet the change: FreedomTechFeed/packages/net/tollgate-wrt/Makefile is the single canonical shipping package; this repo's packaging/Makefile is test/reproducibility only. packaging/README.md lists exactly what the feed consumes (packaging/files/) and the mechanical removal (Package/Build targets, build-ipk/local-build-ipk/build-env helpers, the build-package.yml publish jobs) to execute after the v0.6.0-rc4 release. Draft PR; merged post-release. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * test(harness): pin the env seams the setup script now resolves (release-check green) (#915) Three uci-defaults suites were red at release-check (they are NOT in the per-PR test.yml lane, so #913 merged with them red): - entry-ui-mapping (9): the suite's sed predated the TOLLGATE_ENTRY_UI_CONFIG env override, so the copied 99 read the HOST's live /etc/tollgate/config.json (the harness rot #899 describes). - admin-tls-identity (6) + trusted-entry-80 (1): the new private-WiFi provisional marker defaults to an absolute path the suites did not redirect. Harness-only: pin TOLLGATE_ENTRY_UI_CONFIG + ADMIN/PRIVATE_WIFI_PROVISIONAL_MARKER into each sandbox. NO change to 99-tollgate-setup (the risky half of #899). Sweep: all tests/packaging/* + tests/uci-defaults-* green. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * packaging: re-check the private subnet once the uplink is up (#926) setup_private_network derives network.private (lan/24 ±1) at uci-defaults time, before a fresh DHCP WAN necessarily has a lease, so an upstream that lands on the same /24 as the derived private network — the observed 192.168.2.1 against a 192.168.2.0/24 upstream — is missed. The router then answers for the upstream gateway's own address and DNS breaks on the deployed router. Add recheck_private_subnet (no-op unless the private /24 now overlaps a known upstream) and call it from the postinst AFTER `/etc/init.d/network restart`, reloading the network only when the private subnet had to move. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * fix(tollwallet): bound each mint registration so a slow mint cannot stall boot (#940) * fix(tollwallet): bound each mint registration so a slow mint cannot stall boot The wallet already registers only the mints the startup probe found reachable, but gonuts' AddMint does its own network I/O with NO http.Client timeout, so a mint that accepts the connection and never answers can stall the whole wallet construction — the module's own notes measure 113-347 s for 7 mints. That outlasts the installer's health window and made a healthy install report 'the merchant is degraded'. registerMint now gates AddMint behind a bounded GET /v1/keysets with OUR OWN client (8 s). A mint that does not answer is left UNREGISTERED rather than blocking boot: ensureMintRegistered retries it on first use and the reachable-set callbacks admit it when it recovers. Registration stays sequential, so gonuts is never called concurrently. Test: TestMintServesKeysets (serves keysets -> accepted; empty -> rejected; outruns the bound -> rejected). tollwallet + merchant modules green. * review: reword the bound's guarantee, state the residual, add a defer->recover test Addresses the independent review on the fork PR: - The comment no longer claims AddMint 'cannot hang'. The probe and AddMint are separate exchanges, so the bound makes a hang UNLIKELY rather than impossible; the real closure (a goroutine-with-timeout around AddMint, or registration off the boot path) is noted as a follow-up. - States the residual worst case explicitly: one registerMintKeysetTimeout per reachable mint, sequentially — 7 mints x 8 s = 56 s at boot — plus the new first-use latency for a deferred mint (a bounded <=8 s inline probe on the first payment routed to it; latency, not correctness). - Adds TestRegisterMintDefersThenRecovers: a mint DOWN at boot is left unregistered, and ensureMintRegistered (the path Receive takes) registers it once it recovers — pinning the integration claim through the real gonuts wallet, not just the probe unit. --------- Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * docs(operator-guide): fix the entry_ui=luci TLS-port attribution (#939) The SSL-command scope paragraph paired LuCI's :8443 with entry_ui=luci, inverted: under entry_ui=luci LuCI (uhttpd.main) owns the entry pair TLS port :443 and the board owns :8443. Both ports named in the old sentence belong to the entry_ui=board mapping. Ground truth: packaging/files/etc/uci-defaults/99-tollgate-setup port functions and docs/access-urls.md's mapping table. Found by the PR #744 review; one-line fix-forward. * docs: defer loopback rate-limit vs startup-gate follow-up (post-release) (#953) Record the root cause behind the 2026-10-16 'Setup failed' incident: the installer's own 3s poll exceeds RateLimitMiddleware's 10 req/min for 127.0.0.1, and the limiter runs before requireStarted, so the probe can get 429 {'error':'rate limit exceeded'} instead of the readable 503 {'code':'starting'} refusal. The installer side is worked around in rc33. The root-cause fix (exempt loopback from the limiter) is DEFERRED until after the current release — no new module RC in this line. Co-authored-by: c03rad0r <c03rad0r@users.noreply.github.com> * fix(upstream): select and blacklist upstreams by BSSID, not SSID (#910) * fix(upstream): select and blacklist upstreams by BSSID, not SSID One SSID can be served by several BSSIDs across different upstream networks — some routed to the internet, some captive or isolated. The manager keyed its blacklist by SSID and never pinned the scanned BSSID on the candidate, so a router that settled on a BSSID with no internet blacklisted the whole SSID, including any working sibling, and could never recover. The ICMP-only connectivity check could not tell "gateway but no internet" from "ok" either. - Blacklist by BSSID (falling back to the SSID only when it is unknown), skip candidates by BSSID, and blacklist the exact failed BSSID on an emergency switch. A sibling BSSID of the same SSID stays eligible. - Thread the candidate BSSID through SwitchUpstream and pin wireless.<iface>.bssid, clearing a stale pin when the candidate carries none. Manual CLI connect pins the strongest scanned BSSID for the SSID. - Replace the ICMP-only probe with a layered one — default route, DNS resolution, HTTP 204 captive-portal check — returning a reason (no-route/no-dns/captive/no-http). Used by the startup check, the main loop and post-switch verification. Tests: two-BSSID/one-SSID blacklist isolation, BSSID pin set/clear, and probe classification. make go-battery green across all 16 modules. * changelog: fragment for #910 (BSSID-aware upstream selection) --------- Co-authored-by: c03rad0r <c03rad0r@users.noreply.github.com> * ci(router-test): TBMG Trust Pipeline + bench lanes (dual twin) (#898) * ci(router-test): TBMG Trust Pipeline on the self-hosted runner + verdict comment Router-test is the TBMG Trust Pipeline entry point. Two changes: * Run the gateway dispatch on the self-hosted runner (role 86-tbmg-runner on hermes-nvme) over the private fips mesh with its dedicated per-machine key — no per-repo secrets and no metered LAN traffic. * Add a report job that posts ONE idempotent verdict comment per PR (marker <!-- tbmg-trust -->), so every PR shows whether it is ready to merge. Fork PRs (which must never run on a self-hosted runner) are reported as "not tested" instead of silently skipped. The security contract is preserved: PR -> isolated lab; bench only on main or an explicit dispatch behind bench-hardware. * docs(changelog): fragment for the TBMG Trust Pipeline (#898) * ci(router-test): dispatch the bench lanes on the gateway Add a gated `bench` job (non-PR, bench-hardware) that SSHes to the elected router-bench-gateway over fips and runs scripts/bench/bench-lanes.sh — the lane orchestrator (clean flash -> install -> smoke -> radios OFF, JSON summary). * ci(ngit): mirror the router-test trust pipeline into the ngit twin Land the trust pipeline in the ngit execution twin too (dual setup): same plan/router-test/bench jobs, ngit-safe (no GitHub `environment`/`vars`; the verdict is published by ngit-ci to Nostr, so the report job just summarises). --------- Co-authored-by: felixfelix-bot <felixfelix-bot@users.noreply.github.com> * feat(wireless): wire the vendor-IE price producer the scan boundary staged for (#959) * feat(wireless): wire the vendor-IE price producer the scan boundary staged for PricePerStep/StepSize were declared across four data models (NetworkInfo, Gateway, DiscoveryEntry, KnownTollGate) and had no producer: scanner.go hard-zeroed them with a 'STAGED (this PR)' comment, gated behind the VendorIEDiscovery flag whose consumer did not exist. Wire it: - scanner.go: capture the raw IE blob from the scan's 'IE: <hex>' line and route network assembly through a new buildNetworkInfo(), which calls the existing ParseVendorIEsFromScanData() to fill IsTollGate / RawIEs and the pricing fields. Remove the staged comment. - vendor_element_manager.go: add pricing TLVs (0x03 price_per_step, 0x04 step_size, both 2-byte big-endian) to the encoder and parser, so the IE can carry a price hint. - types.go: TollGateAdvertisement gains PricePerStep/StepSize. Authority is unchanged and now documented in-code: the IE is an UNSIGNED hint via OUI identification that selects a candidate for the signed kind-10021 probe on :2121, which remains the authority for price. A zero pricing value means 'not advertised here', never 'free'. Pricing TLVs are dropped (truncated=true) rather than overflowing the 255-byte element or pushing out the pubkey, because identification outranks the price hint. Tests (observed RED before the change, GREEN after): - TestParseIwinfoOutput_PopulatesVendorIEFields - TestParseIwinfoOutput_NonTollGateIEIgnored - TestParseIwinfoOutput_TollGateIEWithoutPricingTLVs - TestParseIwinfoOutput_NoIEMeansNotTollGate - TestParseIwinfoOutput_MultipleNetworksAndIEs - TestVendorIE_RoundTripPricing (+4 subtests) - TestVendorIE_PricingDroppedRatherThanOverflow Full wireless_gateway_manager suite green, including the five pre-existing vendor-IE tests (no regressions). * fix(wireless): parse the vendor element from the format the router actually emits Hardware verification on tollgate-A04E (2026-10-16) falsified the first implementation's assumption. Measured: - `iwinfo <dev> scan` prints NO information elements at all (grep -c 'IE:' returned 0), so it can never yield IsTollGate via the vendor element. It does report hidden APs, as `ESSID: "unknown"` — a literal placeholder. - `iw dev <dev> scan` is the ONLY element source, and it prints elements DECODED into named fields (SSID:, RSN:, HT capabilities:, ...), not as a hex blob. A vendor element appears as `Vendor specific: <colon-hex>`. - iw strips the element ID (0xDD) and the length byte from that body. Feeding the body straight to ParseVendorIEsFromScanData returns 0 elements: measured as 36 good bytes in, 0 advertisements out. The body must be re-framed with its 0xDD + length prefix. Change: - ParseIwinfoOutput now accepts BOTH real formats: iwinfo `Cell NN - Address:` records and iw `BSS <mac>(on ...)` records, per-record, so a stream carrying either parses. - SSID parsing handles both keys; RSN supplies encryption for iw output (it has no `Encryption:` line); `freq:` supplies the channel. - The vendor element is re-framed before parsing. - Placeholder SSIDs ("", "(hidden)", "unknown") are refused admission, so a literal cannot become a selectable upstream. Tests rewritten against the measured format: TestParseIWScanOutput_{PopulatesVendorIEFields,NonTollGateVendorIgnored, TollGateWithoutPricingTLVs,NoVendorElementMeansNotTollGate, RealHardwareHiddenSSID,MultipleNetworksAndVendorElements}. Three failed against the raw-body bug and pass with the re-frame; the full wireless_gateway_manager suite is green with no regressions. * fix(wireless): read VendorIEDiscovery from UpstreamWifi; scan via iw when enabled Build fix: the flag lives on the UpstreamWifi sub-struct, not on Config. With that wired, the scan path can finally populate the fields the vendor element carries. Two facts measured on tollgate-A04E (2026-10-16) drive the design: 1. `iwinfo <dev> scan` prints NO information elements, so it can never identify a TollGate by vendor element. It is kept because it reports hidden APs (as ESSID "unknown") that iw can omit. 2. `iw dev <dev> scan` is the only element source. When vendor-IE discovery is enabled, scanRadio runs it as well and mergeScanResults folds the two views by BSSID, letting the element-derived fields (IsTollGate, RawIEs, price, step) come from iw while iwinfo still contributes entries iw lacks. iw is best-effort: a failure or empty result keeps the iwinfo view rather than failing the scan. * test(wireless): cover mergeScanResults, scanRadioWithIW and the flag wiring Coverage on the newly added code was incomplete: mergeScanResults and scanRadioWithIW were both at 0%, and parseColonHex at 75%. The merge is the join that lets element-derived fields come from iw while iwinfo-only entries (notably hidden APs) survive, so an untested merge was the largest risk in this change. Added: - mergeScanResults: enriches an existing BSSID from the second view; keeps entries only one view saw; fills gaps (signal/encryption); is the identity when the second view is empty. - scanRadioWithIW: a failed iw invocation returns nil rather than propagating an error, so the caller keeps the iwinfo view. - NewScanner/SetVendorIEDiscovery wiring. Also moves the on-router demo to src/cmd/tg-vendor-ie-demo, so it compiles against the repo's own module graph (the nested scanner module is reached via the existing replace directives) instead of a hand-rolled go.mod. Coverage on the new surface is now: mergeScanResults 100%, appendScanEntry 100%, NewScanner 100%, SetVendorIEDiscovery 100%, ParseIwinfoOutput 98.6%, buildNetworkInfo 92.9%, scanRadioWithIW 70%, parseColonHex 75%. * docs(wireless): record the cross-build target defect found on hardware The demo binary was built with GOOS=linux GOARCH=arm64 but the router rejected it with 'syntax error: unexpected end of file'. Its ELF header read 02 00 3e 00 (e_machine 0x3e = x86-64) instead of 02 00 b7 00 (0xb7 = aarch64). The sha256 matched the local file exactly, so the transfer was correct — the build target was wrong. Adding CGO_ENABLED=0 produced a proper statically linked aarch64 binary. The check that would have caught it in one line, now recorded for reuse: head -c 20 <binary> | od -An -tx1 | tail -1 # want '02 00 b7 00' for aarch64 Size and sha256 are NOT evidence of the right architecture; both matched while the binary was unrunnable on the target. * feat(demo): add --file mode and record the on-router demo evidence The demo could only run 'iw dev <dev> scan' live. Where no neighbour running this build exists (as on tollgate-A04E), that can only ever show the ABSENCE of a vendor element, never the positive identification path. Adding '--file <path>' lets the parser be run against a saved scan, so a synthetic element can be injected into REAL captured router output. Evidence from the live MT6000 (GL.iNet GL-MT6000, OpenWrt 24.10.2, aarch64_cortex-a53), 2026-10-16: A. live scan: iw dev phy0-ap0 scan: 28736 bytes, err=<nil> Vendor specific: lines present = 0 parsed networks = 12 identified TollGates = 0 (correct: no neighbour runs this build) B. real scan + one injected TollGate element: read /tmp/injected-scan.txt: 35168 bytes Vendor specific: lines present = 1 parsed networks = 16 TOLLGATE bssid=b2:36:4a:2a:03:32 ssid="StarGate" signal=0 price_per_step=7 step_size=1000 rawIE_bytes=38 identified TollGates = 1 -> the parser located the element inside genuine scan data and recovered the encoded price/step exactly. The demo binary was cross-built with CGO_ENABLED=0 and its ELF machine field verified as 0xb7 (aarch64) BEFORE transfer — the check whose absence produced the unrunnable x86-64 binary recorded in the previous commit. * fix(wireless): emit pricing before the pubkey so an oversized pubkey fails loudly Defect found by a cold cross-family review (deepseek-flash, PR #959, 2026-10-16). With the original ordering a pubkey could pass the fit check and land the body at 253-255 bytes; the pricing TLVs were then appended AFTER it, the 4-byte guard fired, and the pricing was silently dropped while the element still reported success. A router whose pubkey drifted large would appear to advertise no price — indistinguishable from a router that advertises none. Fix: emit pricing BEFORE the pubkey. An oversized pubkey now fails the existing loud error path ('vendor IE body too long') instead of silently losing the price. The length octet is also clamped explicitly with truncated=true, so a wrap can never produce a malformed element silently. Tests (observed RED against the original ordering, GREEN after): - TestEncode_PricingNotDroppedByPubkey (fails: 'pubkey 239 bytes cannot fit') - TestEncode_PricingSurvivesWithMaxFitPubkey (loud error for oversized pubkey) - TestEncode_LengthOctetNeverWraps (octet == body length) Encoder coverage 87.2%. Full wireless_gateway_manager suite green; go vet clean. MOUNTED BINARY VERIFIED: demo rebuilt for the router and its ELF machine field checked as 02 00 b7 00 (aarch64) before any transfer. Credit: this defect was surfaced by the cross-family lane, not by me. * refactor(wireless): rename the scan-side price hint so it cannot be confused with the authoritative one Follow-up from a cold cross-family review of PR #959 (deepseek lane, nit Q5). NetworkInfo.PricePerStep shared a name with PricingOption.PricePerStep, which IS authoritative (built only from a signature-verified kind-10021 event, and the only field the money path spends). The shared name is what caused the v1 review to raise a false blocker: from the excerpt it could not tell which field was authoritative. Renamed on the scan/display side only: NetworkInfo.PricePerStep -> NetworkInfo.AdvertisedPricePerStep NetworkInfo.StepSize -> NetworkInfo.AdvertisedStepSize DiscoveryEntry / KnownTollGate / cli.UpstreamNetwork likewise. NOT renamed (deliberately, the authoritative/money-side fields): config_manager.MintConfig.PricePerStep tollgate_protocol.PricingOption.PricePerStep TollGateAdvertisement.PricePerStep (the IE encoder's own field) JSON wire keys are UNCHANGED (price_per_step, step_size) - a documented operator-facing contract (docs/operator-guide.md) - so this is a Go identifier change only and is wire-compatible. The field comment now states the authority boundary explicitly, so the next reader does not have to reconstruct validator.go -> session.go to learn that a zero here means "not advertised", never "free". Verified: whole tree builds, go vet clean, wireless_gateway_manager and cli test suites green, no NetworkInfo-side references to the old names remain, and the money path is untouched. * chore: keep session scratch out of the PR PR-BODY.md, PROGRESS.md and build-review-prompt*.py are this session's working artifacts (the PR description and the review prompts), not deliverables. They were swept in by 'git add -A'. Untracked and gitignored; the files stay on disk. Leaving them in would put review-harness scratch in an upstream PR diff. --------- Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * feat(wireless): make the vendor-IE emitter reachable (#960) * feat(wireless): make the vendor-IE emitter reachable, preserving foreign elements STACKED on #959 (pr/vendor-ie-price-producer). The encoder's pricing TLVs exist only there, so this cannot stand alone; do not merge before #959. #959 wired the READ path (scan -> IsTollGate + pricing). Nobody had wired the WRITE path, so no router could advertise itself and the read path had nothing to find: EmitTollGateVendorIE had ZERO callers (dead code) GetLocalAPVendorElements was a stub returning an empty map Hostapd set_vendor_elements REPLACES the whole element set, and the old implementation sent only its own hex Three defects fixed: 1. DEAD CODE. The emitter had no caller and the reader was a stub, so the round-trip (what did I advertise?) could not be verified at all. The reader now reports the last successfully applied set. Documented limitation: this is the processor's own record, NOT a hostapd read-back, because OpenWrt's hostapd exposes set_vendor_elements with no getter. 2. CLOBBERING. set_vendor_elements replaces the entire set, so writing only our own element would WIPE co-resident vendor elements (11k/11v, mesh, other tooling) on every update. Foreign elements observed on the AP are preserved and re-sent alongside ours. Payload ordering is sorted so the same element set always serialises identically. 3. SILENT FAILURE. The old code returned nil even when every hostapd call failed, so a router could fail to advertise and report success - indistinguishable from a router with nothing to say. All-calls-failed is now an error; no-hostapd (radio down) remains a benign no-op. Gating: NewVendorElementProcessor defaults to DISABLED and EmitTollGateVendorIE is a no-op when disabled, so merging this changes nothing until an operator sets vendor_ie_discovery=true. Enforced by tests in both directions. Test seam: added Connector.runUbus, the same injection pattern already used for runUCI (#817), so the emitter's tests exercise the real code path. Tests (observed RED first - the seam and fields did not exist): TestGetLocalAPVendorElements_RoundTripsWhatWasSet TestSetLocalAPVendorElements_PreservesForeignElements TestSetLocalAPVendorElements_FailsLoudWhenAllCallsFail TestSetLocalAPVendorElements_NoHostapdIsNotAnError TestEmitTollGateVendorIE_SendsEncodedElement TestEmitTollGateVendorIE_GatedOffDoesNothing TestNewVendorElementProcessor_DefaultsToDisabled / _EnabledWhenAsked TestVendorElementProcessor_DisabledWritesNothing / _EnabledWrites TestSetLocalAPVendorElements_PayloadIsDeterministic Coverage: SetLocalAPVendorElements 93.9%, GetLocalAPVendorElements 100%, NewVendorElementProcessor 100%, EmitTollGateVendorIE 77.8%. NOT in this PR, stated so the gap is not mistaken for done: the daemon does not yet CALL the emitter on price change or at startup. Doing so needs a re-apply-on-restart story (hostapd loses runtime-only vendor elements), which is its own change. Until that lands, this PR makes the emitter correct and reachable, not automatic. * fix(wireless): make foreign-element preservation real, not theatre Fixes from a cold cross-family review of this PR (deepseek lane, 1 critical + 2 major). All three were cases where the code looked right and the tests passed because the tests supplied the very input production never provided. CRITICAL - preservation was theatre. foreignElements was populated ONLY by tests; nothing in production ever filled it, so the merge loop iterated an empty map and the first write replaced whatever was on the AP. The comment claimed a property the code did not have - the same defect class as the dead-code emitter this PR fixes. Now loadConfiguredVendorElements() populates it from the uci wireless config (where a persisted vendor_elements option lives), skipping our own OUI so it is not duplicated. Verified by mutation: removing the load call makes the new test fail with the clobbering payload '{"vendor_elements":"dd06212121"}' and nothing else. HONEST LIMITATION, now in the field comment: hostapd exposes set_vendor_elements with NO getter, so runtime-only elements CANNOT be read back and nothing in this package can discover them. A caller that knows them must supply them. This is stated rather than papered over. MAJOR - ubus failure looked like "no hostapd". A failed `ubus list` returned nil, making a broken ubus indistinguishable from a healthy router whose radio is off, so a failure to advertise was invisible. It is now an error. MAJOR - partial failure was reported as success. Advertising on 1 of 2 APs returned nil, so a router advertising on ZERO APs could still look fine. A partial result is now surfaced; full failure still errors; no-hostapd remains a benign no-op. MAJOR - the getter under-reported. It recorded only OUR elements, omitting the preserved foreign ones that are equally on the air. It now records the merged set actually sent. REFUTED (not acted on): the review's Q2 claimed the disabled gate is only checked in the emitter so a direct SetLocalAPVendorElements call bypasses it. The guard IS the first statement of SetLocalAPVendorElements. Verified by reading and by the existing TestVendorElementProcessor_DisabledWritesNothing, which asserts zero ubus calls. Recorded here because a refutation is evidence too. Tests (each fails without its fix): TestSetLocalAPVendorElements_PreservesElementsFromUCI (production path only) TestLoadConfiguredVendorElements_DoesNotCaptureOurOwn TestSetLocalAPVendorElements_UbusListFailureIsAnError TestSetLocalAPVendorElements_PartialFailureIsReported TestGetLocalAPVendorElements_ReportsMergedSet TestLoadConfiguredVendorElements_UCIFailureIsTolerated Full wireless_gateway_manager suite green. Credit: all three real defects were found by the cross-family lane; I shipped them. --------- Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * feat(mptcp): multi-WAN bonding integration seam (#927) * feat(mptcp): multi-WAN bonding integration seam for TollGate Adds src/mptcp_bonding, the validated core of an MPTCP multi-WAN bond as an *uplink* concern. Portal, Cashu payment, session accounting and merchant mode are untouched: bonding replaces only how packets leave the box. The package is deliberately pure (no syscalls, no file I/O) so the steering decision lives in one testable place, shared by both the generated firewall rule and any Go caller. policy defaults to guest_only so the box never diverts its own management traffic into the transparent proxy. A naive divert during bring-up cost an operator shell access to a field device. Validation is strict: a half-configured bond silently changes how a paying customer's traffic leaves the device. It therefore rejects fewer than two uplinks, a malformed server or port, guest_only without a parseable CIDR, colliding socks/tproxy ports, duplicate uplinks, and an unparseable fwmark. enabled=false validates with nothing else set. Tests: 10 tests / 32 cases, 92.1 percent coverage, go vet clean, gofmt clean. Written test-first: with the implementation removed the suite fails to build (undefined Config and DefaultSubflows). docs/MPTCP-BONDING.md records what is proven on hardware (MP_JOIN captured on the wire, subflows:2 live, MPTCP verified end-to-end) and what is not: mid-transfer failover is untested, and aggregation is roughly 1.2 to 1.3 times, not 2 times. * feat(mptcp): render MPTCP endpoint + limits provisioning Adds the node-side kernel configuration the bond needs, as pure renderers so the plan is unit-testable without a router: EndpointPlan() one `ip mptcp endpoint add <addr> dev <iface> signal subflow` per uplink with a known address LimitsSetCommand() `ip mptcp limits set subflow …
… (#1103) * test(merchant): late-receive outcome selection raced the owed-grant ERROR line — pick the COMPLETED record by name (#681 red herring) (#718) * test(merchant): duplicate-guard harness needs an ndsctl on PATH — install the renewal fake (#822) (#835) * test(merchant): FiredOnce pinned the opposite of the wired contract — assert fire-exactly-once deterministically (#732) (#836) * test(contract): SSID matcher fence — pinned acceptance table for hasTollGateSSID (#618/#706 classes) (#839) The reader twin of check-ssid-format.sh: what the WRITER may emit is pinned there, what the MATCHER must accept is pinned here. One fixture table (tests/contract/ssid-naming-fixtures.txt) drives both the Go unit test and tests/contract/check-ssid-naming.sh (structural: table covers every required class and the Go pin is wired; behavioural: the real go test runs over the table). Classes: both brands bare (#618 — the matcher was brand-blind and a whitelabel upstream was invisible), case- insensitive (lowercase installer-era SSIDs still in the fleet), single-'!'-decorated with double-'!' rejected (#706). Lands #706's reader-side tolerance (trim at most one '!') so the table is green on merge; #706 then only needs its writer/installer half. The whitelabel fixtures carry the brand literal in the table's file — the SSID contract's allowlisted home in the rebrand gutter (same exemption as check-ssid-format.sh); Go source keeps assembling it from non-contiguous bytes. Negative control: a planted contradictory fixture line turns the fence red (behavioural layer), removed again. Refs #767 Co-authored-by: Amperstrand <dev@amperstrand.no> Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * test(merchant): closeGateCleanup clears an abandoned close streak — the streak outlived the test that spent it (#841) (#842) * fix(wallet,merchant): correlate NUT-07 answers by value; fsync journal directories after rename (#834) (#876) * fix(wallet,merchant): correlate NUT-07 answers by value; fsync journal directories after rename (#834) Two audit-hardening halves of #834: - CheckTokenSpent counted answered states but never correlated each response Y back to a requested one. It now correlates by value: an answer about a Y we did not ask about, or a duplicate answer padding a short reply, is an error instead of a silent verdict — the #502 pending-intent reconciliation (abandoned vs owed) decides on this evidence, and NUT-07 requires states to match the request. - the three money journals (receive intents, owed grants, quote store) fsynced the temp file but not the directory after rename; on ext4-class filesystems the rename itself is only durable after the directory entry is synced, so a power cut could drop the newest journal write whose contents were already fsynced. syncParentDir closes the gap after every rename, surfacing a failed dir fsync as a save error (the record must be treated as not-yet-durable, exactly like a failed temp Sync). * changelog: fragment for #840 (NUT-07 value correlation + journal dir fsyncs) * test(tollwallet): pin the NUT-07 shortfall refusal — direct coverage for the answered-N-of-M branch --------- Co-authored-by: Amperstrand <dev@amperstrand.no> Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * fix(deps): the gonuts pin is one version, everywhere — manifest truth, glob fence, mechanical bump (#791) (#798) The wallet fork's pin drifted three ways in one release week: a hand bump moved four carriers and left a fifth (scripts/token-recovery) three minors behind; two open PRs pinned the same lines to a tag and a pseudo-version; a newer fork tag carrying an open-P2 fix went unnoticed. - packaging/build-inputs.json names the pin (.gonuts.version) as the single source of truth, beside the go and portal pins. - tests/contract/check-gonuts-pin.py discovers carriers by glob (both go.mod require shapes — the single-line form hid the token-recovery stale pin from the checker's first draft) and refuses any disagreement, offline, in pre-commit and both CI twins; --latest (release-check only) refuses a release while the fork has a newer stable tag. - tests/contract/gonuts-pin_test.sh: 7 planted-drift verdicts. - scripts/bump-gonuts.sh rewrites manifest and every carrier together. - scripts/token-recovery raised v0.10.0 -> v0.13.0 (the fence's first catch; builds and vets clean against the new pin). - AGENTS.md states the rule and the check, never a carrier count. Co-authored-by: pr-train-lane <lane@opencode> * fix(packaging): allow pre-auth DNS on UDP, not only TCP — users_to_router udp/53 (#749) (#769) The nodogsplash pre-auth allow list carried tcp/53 (the feed default) but not udp/53, and stub resolvers query UDP first: the ndsRTR chain dropped the query, the client burned a ~5s timeout per attempt before any TCP fallback, and hostname resolution from a captive client was dead in practice — while the portal page itself is usually reached by name (local-lab campaign behind PRTA PR #101). assert_nodogsplash_allow_entries now asserts BOTH protocols, with a new protocol-aware guard (uci_list_has_entry): uci_list_has_port matches a port on either protocol, so guarding udp/53 with it would see the feed's tcp/53 entry and skip the add. tcp/53 is asserted alongside (not left to the feed default) because the writer's contract is that a stale or absent list is repaired on every setup path. tests/uci-defaults-dns-preauth_test.sh pins the matrix against the shipped writer via the fake-uci harness: feed-default list gains udp/53 without duplicating tcp/53, a stale list is repaired on both protocols, the guard is proven protocol-aware (udp present + tcp missing still adds tcp), reruns add nothing, and uci's quoted single-line rendering is handled. The same-version allowlist and convergence suites carry the DNS entries through their full-script runs; CI runs the new suite on every push. Fixes #749 * chore(packaging): pin the portal at the merged network-settings revision (#766) The portal pin moves to 6c25834c (OpenTollGate/tollgate-captive-portal-site#66, merged) — the board half of the operator settings whose module half landed in #604: the Settings cards for the private network's credentials and the administration scope, and the WiFi page's private-radio edit routed through `tollgate config_set` with the section-name contract pinned (both `wireless.private_radio0/1` names and the structural AP-on-`private` signal, read in the netifd `config`-nested shape and the flat fallback) and the half-applied save named rather than swallowed. The gate trail is on the PR: rounds 1-3 CHANGES-REQUESTED, round 4 flagged the structural net reading the wrong shape, round 5 delta verification APPROVED. Evidence: 23/23 Playwright specs across the three admin suites, 4/4 unit tests, tsc clean, packaging guards and the anti-leak gate green. The .secrets.baseline entry for the pin line moves with it, per the baseline's documented purpose of recording reviewed pins: a pin whose hash is not recorded is (correctly) a new alert. As with #714, the pin is the atomicity boundary: the module's rc1 changelog entry already says these settings are settable "on the board" — only a release built after this commit actually carries the board half. Co-authored-by: Amperstrand <amperstrand@localhost> * fix(cloud-lab): Dockerfile.client's GO_VERSION must be a global ARG — the client image cannot build from scratch (#844) * fix(cloud-lab): Dockerfile.client's GO_VERSION must be a GLOBAL arg — the client image cannot build from scratch Dockerfile spec: only args declared before the FIRST FROM are visible to FROM lines. Dockerfile.client declared ARG GO_VERSION=1.26.8 inside the rust-builder stage (after FROM rust:1.88-bookworm) and then used it in the go-builder stage's FROM — where it resolves empty, so every from-scratch build of the client/killer image fails: failed to solve: failed to parse stage name "golang:-bookworm": invalid reference format Introduced by #687's toolchain-parity sweep, which added the ARG to both cloud-lab Dockerfiles — correctly placed in Dockerfile.tollgate (before its first FROM), mid-file in Dockerfile.client. Nothing caught it because the org CI that builds these images has been dark and local lanes ran on cached images. Move the ARG above the first FROM. Verified on the rig (ai-legion, docker 29.1.3/compose 2.40): with this exact change the killer image builds and the #497 crash-injection lane runs to completion on #793's head — pending swap recovered, post-recovery payment 200 (transcript: /root/src/rel-793/transcript-stock.txt on ai-legion). * docs(changelog): fragment for the cloud-lab client global-ARG fix * chore: re-sync the PR head after auto-merge churn (no content change) --------- Co-authored-by: Amperstrand <amperstrand@users.noreply.github.com> Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * fix(merchant): receiveTimeout as per-merchant seam — the package var raced leaked PurchaseSession readers (#821) (#837) * fix(setup): never print generated credentials; add TOLLGATE_PRIVATE_WIFI_PASSWORD (#850) * fix(setup): never print the generated admin/WiFi credentials; add TOLLGATE_PRIVATE_WIFI_PASSWORD * docs(changelog): fragment for the no-print credential change * fix(wireless_gateway_manager): upstream connect refreshes credentials and clears competing STAs (#817) (#848) * fix(wireless_gateway_manager): upstream connect refreshes credentials and clears competing STAs (#817) Two defects chained into the desk-run failure: Reuse: FindOrCreateSTAForSSID returned a disabled STA section for the SSID without writing the new passphrase — a rotated password was dropped, the join failed WRONG_KEY, and the flow reported a DHCP wait. The reuse path now rewrites encryption, key (deleted for open networks) and network=wwan before returning. Exclusivity: mt76 exposes ONE STA netdev per radio, so any other enabled STA section on the target radio keeps the netdev and its (possibly stale or foreign — the desk run's tollgate_uplink) key is what wpa_supplicant joins with while the fresh candidate never gets an interface. SwitchUpstream now disables every enabled STA on the candidate radio, not just the section GetActiveSTA named; the auto-switcher in upstream_manager inherits the same guarantee. Tests drive the real Connector through an in-memory uci fake (the mock-level tests asserted canned returns and never touched this logic); all three new tests are planted-drift-verified to fail on the pre-fix code. * fix(wireless_gateway_manager): address cross-family review — log the stale-key delete, note the revert side effect Two non-blocking findings from #848's review: the open-network transition's key delete now logs failures at debug (the delete is the safeguard itself; an error was silent), and SwitchUpstream's comment states that a reverted switch deliberately leaves competing STAs disabled — the stale key must not regain the netdev — pinned by the revert test. * fix(wireless_gateway_manager): map bare 'WPA PSK' scans to psk-mixed iwinfo reports 'WPA PSK (CCMP)' both for genuine WPA1 networks and for mixed/WPA2-preferring APs whose beacon leads with the WPA1 IE — measured on the lab bench: such an AP rejects a psk section's WPA1 auth and only completes WPA2, so the connect flow wrote a section that could never handshake and reported a DHCP wait (#817's second root cause, same operator-visible symptom as the garbage-key defect). psk-mixed negotiates either way; regression test pins the measured AP shapes. * test(wireless_gateway_manager): pin the psk-mixed mapping in the legacy DetectEncryption table The pre-existing table asserted the old WPA1 mapping for 'WPA PSK (TKIP)'; 0e3da8f deliberately maps that shape to psk-mixed (iwinfo prints the same string for genuine WPA1 and for WPA2-preferring APs whose beacon leads with the WPA1 IE — measured on the bench; psk-mixed negotiates either). Caught by the full go-battery, which the -run filter had skipped via a non-matching test name. --------- Co-authored-by: Amperstrand <dev@amperstrand.no> * fix(setup): never generate a credential the operator cannot see (M1) (#896) * fix(setup): never generate a credential the operator cannot see M1: when root has no password and TOLLGATE_ADMIN_PASSWORD is unset, the package now generates NOTHING and refuses to serve the admin board, instead of creating a provisional credential. A generated-but-withheld password is exactly how routers got locked out (the installer deferred one credential while this package generated another; the operator held neither). The admin password must be provided: as TOLLGATE_ADMIN_PASSWORD at install time, or with `passwd root`, then the gate is re-run. The generate_admin_password/mark_admin_credential_provisional helpers are removed; a provisional marker carried over from an older install is still honoured so an upgraded router keeps telling the operator the credential was generated. * changelog: fragment for #884; fix #850 fragment format The #850 fragment started with a heading instead of a bullet, so changelog-assemble.py check failed on main. Reformatted it as a bullet. --------- Co-authored-by: Your Name <you@example.com> * setup: read the operator credential from a root-only file too (#897) apk-tools 3 (OpenWrt >= 25.12) does NOT forward the caller's environment to package scriptlets, so `TOLLGATE_ADMIN_PASSWORD='…' apk add` never reaches 99-tollgate-setup there: the module saw the env empty and refused the admin board (fail closed, no lockout, but no board). Proven on a GL-MT3000 running vanilla 25.12.5 / apk-tools 3.0.5. operator_chosen_admin_password now falls back to a root-only file (/etc/tollgate/admin-password); the private-WiFi passphrase likewise falls back to /etc/tollgate/wifi-password. Both are consumed (removed) once applied. The env still wins when present, so opkg (where the env IS forwarded) is unchanged. tests/packaging/admin-board-requires-credential_test.sh: +2 cases (staged file applies when the env is empty; env wins over the file). 80 passed, 0 failed. Co-authored-by: Your Name <you@example.com> * setup: never SET a credential — admin or private-WiFi (#884 follow-up) (#902) The package already stopped GENERATING an admin credential (#884), but it still SET one from the install environment (TOLLGATE_ADMIN_PASSWORD / TOLLGATE_PRIVATE_WIFI_PASSWORD) or a staged root-only file (/etc/tollgate/admin-password, /etc/tollgate/wifi-password). That is the same hazard one step over: an operator who pasted the run-book's EXAMPLE value verbatim ended up with root set to a string they never chose, with no link to it. Remove every credential write. 99-tollgate-setup: - drop TOLLGATE_ADMIN_PASSWORD / TOLLGATE_PRIVATE_WIFI_PASSWORD, ADMIN_PASSWORD_FILE / PRIVATE_WIFI_PASSWORD_FILE, operator_chosen_admin_password(), set_admin_password(), clear_admin_credential_provisional() and the generated private-WiFi PSK; - enforce_admin_credential now only ASCERTAINS: set/locked serve, empty/unknown refuse and drop the board, telling the operator to set one with `passwd root` (or LuCI) and re-run; - setup_private_network: preserve an existing passphrase, otherwise leave the private AP DISABLED (wired br-private is unaffected) until one is set; - keep reading the legacy admin-credential-provisional marker on upgrade. CLI: - `tollgate network private set-password` and the config applier now also set disabled=0 on the radio they write a passphrase onto, so setting one actually brings the (disabled-by-default) AP up. Tests: - admin-board-requires-credential: rewritten around the new invariant; ADD the regression that env AND file present leaves root byte-identical, passwd never invoked, the staged file unconsumed, and the board refused; - new tests/uci-defaults-private-credential_test.sh: no passphrase => no key, AP disabled; env/file ignored; existing key preserved+enabled; - new src/cli/network_setpassword_test.go: set-password and the applier enable. Docs: README + unreleased changelog fragments updated to match. Runs green in the module test tiers (only the pre-existing entry-ui-mapping_test.sh failures remain, identical to main). Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * chore: VERSION v0.6.0-rc2 (#903) Stamp the release candidate for the no-generated-credentials work. The feed's PKG_SOURCE_TAG is read from this file, so the built binary reports v0.6.0-rc2 to match the package version 0.6.0_rc2. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * rebase onto main (post-rc2 + #798 fence + credential series): builds: enforce the two-path doctrine — surface contract test, one-pass matrix split, rails (#713) Co-authored-by: Amperstrand <dev@amperstrand.no> * packaging: refuse to install when root has no password (preinst) + VERSION v0.6.0-rc3 (#907) The :8090 admin board's login is checked against root's /etc/shadow hash, and rpcd's session.login accepts ANY password while that hash is empty. The package never sets a credential, so a password-less install would leave an unauthenticatable board. Rather than install and then withhold the board, refuse BEFORE unpacking: the preinst exits non-zero on an empty root hash, so nothing is half-installed and the operator is told to `passwd root` and retry. - preinst (was a deliberate no-op): dependency-free (awk, no jq — the past jq-preinst-aborted-upgrades lesson), allows a locked root, bypasses on IPKG_INSTROOT (offline/image builds) and TOLLGATE_ALLOW_EMPTY_ROOT=1 (CI hatch). - new tests/packaging/preinst-refuses-empty-root_test.sh (9 checks). - VERSION -> v0.6.0-rc3. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * setup: default to a fixed TollGate credential instead of refusing (#913) * setup: default to a fixed TollGate credential instead of refusing The admin board's login is checked against root's /etc/shadow hash, and rpcd's session.login accepts ANY password while it is empty. rc3 refused to install in that state, but a failing pre-install is NON-FATAL under apk-tools 3 (the .apk landed anyway) and requiring `passwd root` before install burdened every operator. Replace the refusal with a FIXED default: 99-tollgate-setup sets root to TOLLGATE_DEFAULT_CREDENTIAL ("TollGate") when none exists, sets the private (management) WiFi key to the same value, marks the admin credential PROVISIONAL, and serves the board. The admin/config UI forces a change at first login (shipped separately, post-release). No install-time credential is accepted (TOLLGATE_ADMIN_PASSWORD / staged file stay ignored); an operator's own credential is preserved; the write still fails CLOSED if it cannot take. - preinst refusal -> no-op (the two lanes agree now). - new tests/packaging/admin-board-defaults-to-tollgate_test.sh (59 checks); preinst-refuses-empty-root -> preinst-never-refuses; the private-credential and configui-8090 suites updated to the default contract. - VERSION -> v0.6.0-rc4. * setup: mark the defaulted private-WiFi key provisional too setup_private_network writes /etc/tollgate/private-wifi-credential-provisional when it installs the fixed default passphrase (rather than one the operator chose), so the post-release admin/config UI can force a Wi-Fi passphrase change at first login alongside the admin one. The test asserts it is written only when the default is applied. --------- Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * docs(packaging): record the decommission plan for the module's own package build (#914) Freeze the intent, not yet the change: FreedomTechFeed/packages/net/tollgate-wrt/Makefile is the single canonical shipping package; this repo's packaging/Makefile is test/reproducibility only. packaging/README.md lists exactly what the feed consumes (packaging/files/) and the mechanical removal (Package/Build targets, build-ipk/local-build-ipk/build-env helpers, the build-package.yml publish jobs) to execute after the v0.6.0-rc4 release. Draft PR; merged post-release. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * test(harness): pin the env seams the setup script now resolves (release-check green) (#915) Three uci-defaults suites were red at release-check (they are NOT in the per-PR test.yml lane, so #913 merged with them red): - entry-ui-mapping (9): the suite's sed predated the TOLLGATE_ENTRY_UI_CONFIG env override, so the copied 99 read the HOST's live /etc/tollgate/config.json (the harness rot #899 describes). - admin-tls-identity (6) + trusted-entry-80 (1): the new private-WiFi provisional marker defaults to an absolute path the suites did not redirect. Harness-only: pin TOLLGATE_ENTRY_UI_CONFIG + ADMIN/PRIVATE_WIFI_PROVISIONAL_MARKER into each sandbox. NO change to 99-tollgate-setup (the risky half of #899). Sweep: all tests/packaging/* + tests/uci-defaults-* green. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * packaging: re-check the private subnet once the uplink is up (#926) setup_private_network derives network.private (lan/24 ±1) at uci-defaults time, before a fresh DHCP WAN necessarily has a lease, so an upstream that lands on the same /24 as the derived private network — the observed 192.168.2.1 against a 192.168.2.0/24 upstream — is missed. The router then answers for the upstream gateway's own address and DNS breaks on the deployed router. Add recheck_private_subnet (no-op unless the private /24 now overlaps a known upstream) and call it from the postinst AFTER `/etc/init.d/network restart`, reloading the network only when the private subnet had to move. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * fix(tollwallet): bound each mint registration so a slow mint cannot stall boot (#940) * fix(tollwallet): bound each mint registration so a slow mint cannot stall boot The wallet already registers only the mints the startup probe found reachable, but gonuts' AddMint does its own network I/O with NO http.Client timeout, so a mint that accepts the connection and never answers can stall the whole wallet construction — the module's own notes measure 113-347 s for 7 mints. That outlasts the installer's health window and made a healthy install report 'the merchant is degraded'. registerMint now gates AddMint behind a bounded GET /v1/keysets with OUR OWN client (8 s). A mint that does not answer is left UNREGISTERED rather than blocking boot: ensureMintRegistered retries it on first use and the reachable-set callbacks admit it when it recovers. Registration stays sequential, so gonuts is never called concurrently. Test: TestMintServesKeysets (serves keysets -> accepted; empty -> rejected; outruns the bound -> rejected). tollwallet + merchant modules green. * review: reword the bound's guarantee, state the residual, add a defer->recover test Addresses the independent review on the fork PR: - The comment no longer claims AddMint 'cannot hang'. The probe and AddMint are separate exchanges, so the bound makes a hang UNLIKELY rather than impossible; the real closure (a goroutine-with-timeout around AddMint, or registration off the boot path) is noted as a follow-up. - States the residual worst case explicitly: one registerMintKeysetTimeout per reachable mint, sequentially — 7 mints x 8 s = 56 s at boot — plus the new first-use latency for a deferred mint (a bounded <=8 s inline probe on the first payment routed to it; latency, not correctness). - Adds TestRegisterMintDefersThenRecovers: a mint DOWN at boot is left unregistered, and ensureMintRegistered (the path Receive takes) registers it once it recovers — pinning the integration claim through the real gonuts wallet, not just the probe unit. --------- Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com> * docs(operator-guide): fix the entry_ui=luci TLS-port attribution (#939) The SSL-command scope paragraph paired LuCI's :8443 with entry_ui=luci, inverted: under entry_ui=luci LuCI (uhttpd.main) owns the entry pair TLS port :443 and the board owns :8443. Both ports named in the old sentence belong to the entry_ui=board mapping. Ground truth: packaging/files/etc/uci-defaults/99-tollgate-setup port functions and docs/access-urls.md's mapping table. Found by the PR #744 review; one-line fix-forward. * docs: defer loopback rate-limit vs startup-gate follow-up (post-release) (#953) Record the root cause behind the 2026-10-16 'Setup failed' incident: the installer's own 3s poll exceeds RateLimitMiddleware's 10 req/min for 127.0.0.1, and the limiter runs before requireStarted, so the probe can get 429 {'error':'rate limit exceeded'} instead of the readable 503 {'code':'starting'} refusal. The installer side is worked around in rc33. The root-cause fix (exempt loopback from the limiter) is DEFERRED until after the current release — no new module RC in this line. Co-authored-by: c03rad0r <c03rad0r@users.noreply.github.com> * fix(upstream): select and blacklist upstreams by BSSID, not SSID (#910) * fix(upstream): select and blacklist upstreams by BSSID, not SSID One SSID can be served by several BSSIDs across different upstream networks — some routed to the internet, some captive or isolated. The manager keyed its blacklist by SSID and never pinned the scanned BSSID on the candidate, so a router that settled on a BSSID with no internet blacklisted the whole SSID, including any working sibling, and could never recover. The ICMP-only connectivity check could not tell "gateway but no internet" from "ok" either. - Blacklist by BSSID (falling back to the SSID only when it is unknown), skip candidates by BSSID, and blacklist the exact failed BSSID on an emergency switch. A sibling BSSID of the same SSID stays eligible. - Thread the candidate BSSID through SwitchUpstream and pin wireless.<iface>.bssid, clearing a stale pin when the candidate carries none. Manual CLI connect pins the strongest scanned BSSID for the SSID. - Replace the ICMP-only probe with a layered one — default route, DNS resolution, HTTP 204 captive-portal check — returning a reason (no-route/no-dns/captive/no-http). Used by the startup check, the main loop and post-switch verification. Tests: two-BSSID/one-SSID blacklist isolation, BSSID pin set/clear, and probe classification. make go-battery green across all 16 modules. * changelog: fragment for #910 (BSSID-aware upstream selection) --------- Co-authored-by: c03rad0r <c03rad0r@users.noreply.github.com> * ci(router-test): TBMG Trust Pipeline + bench lanes (dual twin) (#898) * ci(router-test): TBMG Trust Pipeline on the self-hosted runner + verdict comment Router-test is the TBMG Trust Pipeline entry point. Two changes: * Run the gateway dispatch on the self-hosted runner (role 86-tbmg-runner on hermes-nvme) over the private fips mesh with its dedicated per-machine key — no per-repo secrets and no metered LAN traffic. * Add a report job that posts ONE idempotent verdict comment per PR (marker <!-- tbmg-trust -->), so every PR shows whether it is ready to merge. Fork PRs (which must never run on a self-hosted runner) are reported as "not tested" instead of silently skipped. The security contract is preserved: PR -> isolated lab; bench only on main or an explicit dispatch behind bench-hardware. * docs(changelog): fragment for the TBMG Trust Pipeline (#898) * ci(router-test): dispatch the bench lanes on the gateway Add a gated `bench` job (non-PR, bench-hardware) that SSHes to the elected router-bench-gateway over fips and runs scripts/bench/bench-lanes.sh — the lane orchestrator (clean flash -> install -> smoke -> radios OFF, JSON summary). * ci(ngit): mirror the router-test trust pipeline into the ngit twin Land the trust pipeline in the ngit execution twin too (dual setup): same plan/router-test/bench jobs, ngit-safe (no GitHub `environment`/`vars`; the verdict is published by ngit-ci to Nostr, so the report job just summarises). --------- Co-authored-by: felixfelix-bot <felixfelix-bot@users.noreply.github.com> * feat(wireless): wire the vendor-IE price producer the scan boundary staged for (#959) * feat(wireless): wire the vendor-IE price producer the scan boundary staged for PricePerStep/StepSize were declared across four data models (NetworkInfo, Gateway, DiscoveryEntry, KnownTollGate) and had no producer: scanner.go hard-zeroed them with a 'STAGED (this PR)' comment, gated behind the VendorIEDiscovery flag whose consumer did not exist. Wire it: - scanner.go: capture the raw IE blob from the scan's 'IE: <hex>' line and route network assembly through a new buildNetworkInfo(), which calls the existing ParseVendorIEsFromScanData() to fill IsTollGate / RawIEs and the pricing fields. Remove the staged comment. - vendor_element_manager.go: add pricing TLVs (0x03 price_per_step, 0x04 step_size, both 2-byte big-endian) to the encoder and parser, so the IE can carry a price hint. - types.go: TollGateAdvertisement gains PricePerStep/StepSize. Authority is unchanged and now documented in-code: the IE is an UNSIGNED hint via OUI identification that selects a candidate for the signed kind-10021 probe on :2121, which remains the authority for price. A zero pricing value means 'not advertised here', never 'free'. Pricing TLVs are dropped (truncated=true) rather than overflowing the 255-byte element or pushing out the pubkey, because identification outranks the price hint. Tests (observed RED before the change, GREEN after): - TestParseIwinfoOutput_PopulatesVendorIEFields - TestParseIwinfoOutput_NonTollGateIEIgnored - TestParseIwinfoOutput_TollGateIEWithoutPricingTLVs - TestParseIwinfoOutput_NoIEMeansNotTollGate - TestParseIwinfoOutput_MultipleNetworksAndIEs - TestVendorIE_RoundTripPricing (+4 subtests) - TestVendorIE_PricingDroppedRatherThanOverflow Full wireless_gateway_manager suite green, including the five pre-existing vendor-IE tests (no regressions). * fix(wireless): parse the vendor element from the format the router actually emits Hardware verification on tollgate-A04E (2026-10-16) falsified the first implementation's assumption. Measured: - `iwinfo <dev> scan` prints NO information elements at all (grep -c 'IE:' returned 0), so it can never yield IsTollGate via the vendor element. It does report hidden APs, as `ESSID: "unknown"` — a literal placeholder. - `iw dev <dev> scan` is the ONLY element source, and it prints elements DECODED into named fields (SSID:, RSN:, HT capabilities:, ...), not as a hex blob. A vendor element appears as `Vendor specific: <colon-hex>`. - iw strips the element ID (0xDD) and the length byte from that body. Feeding the body straight to ParseVendorIEsFromScanData returns 0 elements: measured as 36 good bytes in, 0 advertisements out. The body must be re-framed with its 0xDD + length prefix. Change: - ParseIwinfoOutput now accepts BOTH real formats: iwinfo `Cell NN - Address:` records and iw `BSS <mac>(on ...)` records, per-record, so a stream carrying either parses. - SSID parsing handles both keys; RSN supplies encryption for iw output (it has no `Encryption:` line); `freq:` supplies the channel. - The vendor element is re-framed before parsing. - Placeholder SSIDs ("", "(hidden)", "unknown") are refused admission, so a literal cannot become a selectable upstream. Tests rewritten against the measured format: TestParseIWScanOutput_{PopulatesVendorIEFields,NonTollGateVendorIgnored, TollGateWithoutPricingTLVs,NoVendorElementMeansNotTollGate, RealHardwareHiddenSSID,MultipleNetworksAndVendorElements}. Three failed against the raw-body bug and pass with the re-frame; the full wireless_gateway_manager suite is green with no regressions. * fix(wireless): read VendorIEDiscovery from UpstreamWifi; scan via iw when enabled Build fix: the flag lives on the UpstreamWifi sub-struct, not on Config. With that wired, the scan path can finally populate the fields the vendor element carries. Two facts measured on tollgate-A04E (2026-10-16) drive the design: 1. `iwinfo <dev> scan` prints NO information elements, so it can never identify a TollGate by vendor element. It is kept because it reports hidden APs (as ESSID "unknown") that iw can omit. 2. `iw dev <dev> scan` is the only element source. When vendor-IE discovery is enabled, scanRadio runs it as well and mergeScanResults folds the two views by BSSID, letting the element-derived fields (IsTollGate, RawIEs, price, step) come from iw while iwinfo still contributes entries iw lacks. iw is best-effort: a failure or empty result keeps the iwinfo view rather than failing the scan. * test(wireless): cover mergeScanResults, scanRadioWithIW and the flag wiring Coverage on the newly added code was incomplete: mergeScanResults and scanRadioWithIW were both at 0%, and parseColonHex at 75%. The merge is the join that lets element-derived fields come from iw while iwinfo-only entries (notably hidden APs) survive, so an untested merge was the largest risk in this change. Added: - mergeScanResults: enriches an existing BSSID from the second view; keeps entries only one view saw; fills gaps (signal/encryption); is the identity when the second view is empty. - scanRadioWithIW: a failed iw invocation returns nil rather than propagating an error, so the caller keeps the iwinfo view. - NewScanner/SetVendorIEDiscovery wiring. Also moves the on-router demo to src/cmd/tg-vendor-ie-demo, so it compiles against the repo's own module graph (the nested scanner module is reached via the existing replace directives) instead of a hand-rolled go.mod. Coverage on the new surface is now: mergeScanResults 100%, appendScanEntry 100%, NewScanner 100%, SetVendorIEDiscovery 100%, ParseIwinfoOutput 98.6%, buildNetworkInfo 92.9%, scanRadioWithIW 70%, parseColonHex 75%. * docs(wireless): record the cross-build target defect found on hardware The demo binary was built with GOOS=linux GOARCH=arm64 but the router rejected it with 'syntax error: unexpected end of file'. Its ELF header read 02 00 3e 00 (e_machine 0x3e = x86-64) instead of 02 00 b7 00 (0xb7 = aarch64). The sha256 matched the local file exactly, so the transfer was correct — the build target was wrong. Adding CGO_ENABLED=0 produced a proper statically linked aarch64 binary. The check that would have caught it in one line, now recorded for reuse: head -c 20 <binary> | od -An -tx1 | tail -1 # want '02 00 b7 00' for aarch64 Size and sha256 are NOT evidence of the right architecture; both matched while the binary was unrunnable on the target. * feat(demo): add --file mode and record the on-router demo evidence The demo could only run 'iw dev <dev> scan' live. Where no neighbour running this build exists (as on tollgate-A04E), that can only ever show the ABSENCE of a vendor element, never the positive identification path. Adding '--file <path>' lets the parser be run against a saved scan, so a synthetic element can be injected into REAL captured router output. Evidence from the live MT6000 (GL.iNet GL-MT6000, OpenWrt 24.10.2, aarch64_cortex-a53), 2026-10-16: A. live scan: iw dev phy0-ap0 scan: 28736 bytes, err=<nil> Vendor specific: lines present = 0 parsed networks = 12 identified TollGates = 0 (correct: no neighbour runs this build) B. real scan + one injected TollGate element: read /tmp/injected-scan.txt: 35168 bytes Vendor specific: lines present = 1 parsed networks = 16 TOLLGATE bssid=b2:36:4a:2a:03:32 ssid="StarGate" signal=0 price_per_step=7 step_size=1000 rawIE_bytes=38 identified TollGates = 1 -> the parser located the element inside genuine scan data and recovered the encoded price/step exactly. The demo binary was cross-built with CGO_ENABLED=0 and its ELF machine field verified as 0xb7 (aarch64) BEFORE transfer — the check whose absence produced the unrunnable x86-64 binary recorded in the previous commit. * fix(wireless): emit pricing before the pubkey so an oversized pubkey fails loudly Defect found by a cold cross-family review (deepseek-flash, PR #959, 2026-10-16). With the original ordering a pubkey could pass the fit check and land the body at 253-255 bytes; the pricing TLVs were then appended AFTER it, the 4-byte guard fired, and the pricing was silently dropped while the element still reported success. A router whose pubkey drifted large would appear to advertise no price — indistinguishable from a router that advertises none. Fix: emit pricing BEFORE the pubkey. An oversized pubkey now fails the existing loud error path ('vendor IE body too long') instead of silently losing the price. The length octet is also clamped explicitly with truncated=true, so a wrap can never produce a malformed element silently. Tests (observed RED against the original ordering, GREEN after): - TestEncode_PricingNotDroppedByPubkey (fails: 'pubkey 239 bytes cannot fit') - TestEncode_PricingSurvivesWithMaxFitPubkey (loud error for oversized pubkey) - TestEncode_LengthOctetNeverWraps (octet == body length) Encoder coverage 87.2%. Full wireless_gateway_manager suite green; go vet clean. MOUNTED BINARY VERIFIED: demo rebuilt for the router and its ELF machine field checked as 02 00 b7 00 (aarch64) before any transfer. Credit: this defect was surfaced by the cross-family lane, not by me. * refactor(wireless): rename the scan-side price hint so it cannot be confused with the authoritative one Follow-up from a cold cross-family review of PR #959 (deepseek lane, nit Q5). NetworkInfo.PricePerStep shared a name with PricingOption.PricePerStep, which IS authoritative (built only from a signature-verified kind-10021 event, and the only field the money path spends). The shared name is what caused the v1 review to raise a false blocker: from the excerpt it could not tell which field was authoritative. Renamed on the scan/display side only: NetworkInfo.PricePerStep -> NetworkInfo.AdvertisedPricePerStep NetworkInfo.StepSize -> NetworkInfo.AdvertisedStepSize DiscoveryEntry / KnownTollGate / cli.UpstreamNetwork likewise. NOT renamed (deliberately, the authoritative/money-side fields): config_manager.MintConfig.PricePerStep tollgate_protocol.PricingOption.PricePerStep TollGateAdvertisement.PricePerStep (the IE encoder's own field) JSON wire keys are UNCHANGED (price_per_step, step_size) - a documented operator-facing contract (docs/operator-guide.md) - so this is a Go identifier change only and is wire-compatible. The field comment now states the authority boundary explicitly, so the next reader does not have to reconstruct validator.go -> session.go to learn that a zero here means "not advertised", never "free". Verified: whole tree builds, go vet clean, wireless_gateway_manager and cli test suites green, no NetworkInfo-side references to the old names remain, and the money path is untouched. * chore: keep session scratch out of the PR PR-BODY.md, PROGRESS.md and build-review-prompt*.py are this session's working artifacts (the PR description and the review prompts), not deliverables. They were swept in by 'git add -A'. Untracked and gitignored; the files stay on disk. Leaving them in would put review-harness scratch in an upstream PR diff. --------- Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * feat(wireless): make the vendor-IE emitter reachable (#960) * feat(wireless): make the vendor-IE emitter reachable, preserving foreign elements STACKED on #959 (pr/vendor-ie-price-producer). The encoder's pricing TLVs exist only there, so this cannot stand alone; do not merge before #959. #959 wired the READ path (scan -> IsTollGate + pricing). Nobody had wired the WRITE path, so no router could advertise itself and the read path had nothing to find: EmitTollGateVendorIE had ZERO callers (dead code) GetLocalAPVendorElements was a stub returning an empty map Hostapd set_vendor_elements REPLACES the whole element set, and the old implementation sent only its own hex Three defects fixed: 1. DEAD CODE. The emitter had no caller and the reader was a stub, so the round-trip (what did I advertise?) could not be verified at all. The reader now reports the last successfully applied set. Documented limitation: this is the processor's own record, NOT a hostapd read-back, because OpenWrt's hostapd exposes set_vendor_elements with no getter. 2. CLOBBERING. set_vendor_elements replaces the entire set, so writing only our own element would WIPE co-resident vendor elements (11k/11v, mesh, other tooling) on every update. Foreign elements observed on the AP are preserved and re-sent alongside ours. Payload ordering is sorted so the same element set always serialises identically. 3. SILENT FAILURE. The old code returned nil even when every hostapd call failed, so a router could fail to advertise and report success - indistinguishable from a router with nothing to say. All-calls-failed is now an error; no-hostapd (radio down) remains a benign no-op. Gating: NewVendorElementProcessor defaults to DISABLED and EmitTollGateVendorIE is a no-op when disabled, so merging this changes nothing until an operator sets vendor_ie_discovery=true. Enforced by tests in both directions. Test seam: added Connector.runUbus, the same injection pattern already used for runUCI (#817), so the emitter's tests exercise the real code path. Tests (observed RED first - the seam and fields did not exist): TestGetLocalAPVendorElements_RoundTripsWhatWasSet TestSetLocalAPVendorElements_PreservesForeignElements TestSetLocalAPVendorElements_FailsLoudWhenAllCallsFail TestSetLocalAPVendorElements_NoHostapdIsNotAnError TestEmitTollGateVendorIE_SendsEncodedElement TestEmitTollGateVendorIE_GatedOffDoesNothing TestNewVendorElementProcessor_DefaultsToDisabled / _EnabledWhenAsked TestVendorElementProcessor_DisabledWritesNothing / _EnabledWrites TestSetLocalAPVendorElements_PayloadIsDeterministic Coverage: SetLocalAPVendorElements 93.9%, GetLocalAPVendorElements 100%, NewVendorElementProcessor 100%, EmitTollGateVendorIE 77.8%. NOT in this PR, stated so the gap is not mistaken for done: the daemon does not yet CALL the emitter on price change or at startup. Doing so needs a re-apply-on-restart story (hostapd loses runtime-only vendor elements), which is its own change. Until that lands, this PR makes the emitter correct and reachable, not automatic. * fix(wireless): make foreign-element preservation real, not theatre Fixes from a cold cross-family review of this PR (deepseek lane, 1 critical + 2 major). All three were cases where the code looked right and the tests passed because the tests supplied the very input production never provided. CRITICAL - preservation was theatre. foreignElements was populated ONLY by tests; nothing in production ever filled it, so the merge loop iterated an empty map and the first write replaced whatever was on the AP. The comment claimed a property the code did not have - the same defect class as the dead-code emitter this PR fixes. Now loadConfiguredVendorElements() populates it from the uci wireless config (where a persisted vendor_elements option lives), skipping our own OUI so it is not duplicated. Verified by mutation: removing the load call makes the new test fail with the clobbering payload '{"vendor_elements":"dd06212121"}' and nothing else. HONEST LIMITATION, now in the field comment: hostapd exposes set_vendor_elements with NO getter, so runtime-only elements CANNOT be read back and nothing in this package can discover them. A caller that knows them must supply them. This is stated rather than papered over. MAJOR - ubus failure looked like "no hostapd". A failed `ubus list` returned nil, making a broken ubus indistinguishable from a healthy router whose radio is off, so a failure to advertise was invisible. It is now an error. MAJOR - partial failure was reported as success. Advertising on 1 of 2 APs returned nil, so a router advertising on ZERO APs could still look fine. A partial result is now surfaced; full failure still errors; no-hostapd remains a benign no-op. MAJOR - the getter under-reported. It recorded only OUR elements, omitting the preserved foreign ones that are equally on the air. It now records the merged set actually sent. REFUTED (not acted on): the review's Q2 claimed the disabled gate is only checked in the emitter so a direct SetLocalAPVendorElements call bypasses it. The guard IS the first statement of SetLocalAPVendorElements. Verified by reading and by the existing TestVendorElementProcessor_DisabledWritesNothing, which asserts zero ubus calls. Recorded here because a refutation is evidence too. Tests (each fails without its fix): TestSetLocalAPVendorElements_PreservesElementsFromUCI (production path only) TestLoadConfiguredVendorElements_DoesNotCaptureOurOwn TestSetLocalAPVendorElements_UbusListFailureIsAnError TestSetLocalAPVendorElements_PartialFailureIsReported TestGetLocalAPVendorElements_ReportsMergedSet TestLoadConfiguredVendorElements_UCIFailureIsTolerated Full wireless_gateway_manager suite green. Credit: all three real defects were found by the cross-family lane; I shipped them. --------- Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com> * feat(mptcp): multi-WAN bonding integration seam (#927) * feat(mptcp): multi-WAN bonding integration seam for TollGate Adds src/mptcp_bonding, the validated core of an MPTCP multi-WAN bond as an *uplink* concern. Portal, Cashu payment, session accounting and merchant mode are untouched: bonding replaces only how packets leave the box. The package is deliberately pure (no syscalls, no file I/O) so the steering decision lives in one testable place, shared by both the generated firewall rule and any Go caller. policy defaults to guest_only so the box never diverts its own management traffic into the transparent proxy. A naive divert during bring-up cost an operator shell access to a field device. Validation is strict: a half-configured bond silently changes how a paying customer's traffic leaves the device. It therefore rejects fewer than two uplinks, a malformed server or port, guest_only without a parseable CIDR, colliding socks/tproxy ports, duplicate uplinks, and an unparseable fwmark. enabled=false validates with nothing else set. Tests: 10 tests / 32 cases, 92.1 percent coverage, go vet clean, gofmt clean. Written test-first: with the implementation removed the suite fails to build (undefined Config and DefaultSubflows). docs/MPTCP-BONDING.md records what is proven on hardware (MP_JOIN captured on the wire, subflows:2 live, MPTCP verified end-to-end) and what is not: mid-transfer failover is untested, and aggregation is roughly 1.2 to 1.3 times, not 2 times. * feat(mptcp): render MPTCP endpoint + limits provisioning Adds the node-side kernel configuration the bond needs, as pure renderers so the plan is unit-testable without a router: EndpointPlan() one `ip mptcp endpoint add <addr> dev <iface> signal subflow` per uplink with a known address LimitsSetCommand() `ip mptcp limits set subflow N add_addr_accepted N`, raising the subflow ceiling to the uplink count but never lowering it below PrerequisitePackages() names ip-full Both renderers return nothing when the bond is disabled, so a device that has not opted in emits no commands. These pin three failures the lab found, all of which are silent on device: * a `subflow`-only endpoint never sends ADD_ADDR, so the peer never learns the address and no second subflow can form. It looks configured and does nothing. Every endpoint must be `signal subflow`. * the kernel default of 2 subflows is below what a multi-WAN box needs; the extra uplinks would be configured but never used. * BusyBox `ip` has no `mptcp` subcommand, so ip-full is required or every command fails with a usage error on a stock image. Docs record the FIPS-side change this depends on: enabling MPTCP on the node cannot make the socket multipath-capable, because FIPS creates its own socket. The transport patch is carried on the fork at felixfelix-bot/fips @ pr/tcp-mptcp-option (documented, not PR'd upstream pending the aggregation measurement). Tests: 8 new, all RED first. Package coverage 90.2%. go vet and gofmt clean. Refs: docs/MPTCP-BONDING.md * docs(mptcp): lab measurement - subflow formation proven, aggregation topology-limited Records the first successful two-disjoint-path MPTCP measurement on the GL-MT6000 lab router (tollgate-A04E, OpenWrt 24.10.2). Subflow formation is PROVEN. With both ends configured `signal subflow`, a real second subflow forms and completes its handshake: MPJoinSynRx 0 -> 2 (JOINs received) MPJoinAckRx 0 -> 2 (handshakes completed) ss -Min: subflows:1 subflows_total:2 local_addr_used:3 That counter stayed at 0 in every earlier run; the `signal` flag is what unblocked it. Aggregation is NOT achieved, and the reason is topological rather than a defect in this code. Measured on two disjoint L2 segments that both terminate at the same router: single path (--bind-dev) 925-928 Mbit/s unbound (MPTCP free to use both) 919-921 Mbit/s No gain. Conntrack shows only one of the two MPTCP connections carrying data: both subflows converge at one router with one upstream, so the scheduler keeps traffic on the better subflow rather than splitting across a shared bottleneck. Stated plainly so the docs do not overclaim: subflow formation yes, throughput aggregation no, and independent end-to-end paths are required to demonstrate the latter. Refs: docs/MPTCP-MEASUREMENT.md * chore(mptcp): track PROGRESS.md for cross-iteration resumability Records iteration 1 findings so a capped-out or re-dispatched worker can resume without re-running the lab work: * the lab DOES have two disjoint L2 paths (supersedes the earlier single-link claim); * client endpoints were subflow-only, missing signal -- real blocker; * subflow formation is PROVEN (MPJoinSynRx 0 -> 2); * aggregation is topology-limited (two L2s, one upstream) and the conntrack evidence shows only one connection carrying data. Refs: docs/MPTCP-MEASUREMENT.md * feat(mptcp): idempotent applier that renders the bond onto the device Adds Config.ApplyScript(), which renders the whole bond as one ordered, re-runnable shell script. It is a renderer, not an executor: no I/O, no syscalls, so the exact bytes can be asserted in a unit test and the same script handed to whatever applies it (systemd, the module's applier, an Ansible task, or an operator reading it). Ordering is load-bearing and now enforced by test: 1. Preconditions first -- `ip` present, net.mptcp.enabled=1, and an `ip` that actually has the mptcp subcommand. Without this the failure mode is a confusing "unknown command" from BusyBox ip halfway through, with the device left partially configured. 2. `ip mptcp limits set` BEFORE any endpoint exists. Raising the ceiling after registration would leave the first endpoints rejected. 3. Endpoints, each preceded by `ip mptcp endpoint delete <addr>` so a re-run cannot fail on an already-registered address. Idempotence is the point. A reconcile runs on every boot and every interface change; a script that only works the first time leaves a field device unconfigured after one restart. The delete-then-add guard is what makes a repeat run a no-op instead of an error. The bind password is deliberately never rendered: it belongs in the transport's own configuration, and writing it here would put a credential in a file at a predictable path. Verification: * 4 new tests, written RED first (build failed with `ApplyScript undefined`). * package coverage 90.3%, go vet clean, gofmt clean. * rendered script passes `sh -n` (valid shell). * EXECUTED TWICE on the real router (GL-MT6000 tollgate-A04E) and the post-condition read back from the device: RUN1_OK, RUN2_OK, endpoint set byte-identical before and after both runs, limits correct at `add_addr_accepted 4 subflows 4`. No duplicates, no failures. * chore(mptcp): PROGRESS iteration 2 - applier done, proven idempotent on device ApplyScript committed as d927deb; verified by running it twice on the GL-MT6000 with the post-condition read back from the device (identical endpoint set, RUN1_OK/RUN2_OK, limits correct). Flags the one remaining operator decision: aggregation needs a second end-to-end path, which this lab does not have. * docs(mptcp): operator runbook for enabling a bond on OpenWrt Adds docs/MPTCP-OPERATOR-RUNBOOK.md plus scripts/read_mptcp_counter.sh, so an operator can enable and, more importantly, VERIFY a bond without rediscovering the lab's failure modes. The runbook leads with the honest expectation (resilient multi-path, 1.2-1.3x, not 2x) and then documents the three silent failures, each of which leaves a bond looking configured while carrying everything on one path: 1. An endpoint must be `signal subflow`. Without `signal` the kernel never sends ADD_ADDR, the peer never learns the address, and MPTcpExtMPJoinSynRx stays 0 forever while the config looks right. 2. The kernel default of 2 subflows is below a multi-WAN box's needs; `ip mptcp limits set subflow N add_addr_accepted N` must cover the uplink count. 3. Two addresses on one L2 is not two paths. The runbook includes a three-row table contrasting one-L2 / two-disjoint-L2s-one-upstream / two-disjoint-L2s-two-upstreams, with what each one actually yields -- because "the subflow formed but throughput did not change" is the confusing case and it is a topology property, not a misconfiguration. Every command in the runbook was executed against the real router before being written down, and that caught three defects in my own draft: * `ss -Min` prints NOTHING for MPTCP sockets on this OpenWrt build, so the runbook no longer tells operators to read subflows from it; * `paste` is not in BusyBox, so the obvious awk|tr|paste one-liner fails on OpenWrt -- replaced with scripts/read_mptcp_counter.sh; * `ip route get` against the router's OWN address returns `local` and proves nothing, so the disjoint-path check is now specified as client-side and against the peer's addresses. read_mptcp_counter.sh walks the positional MPTcpExt header/values lines with POSIX shell only. Verified ON THE DEVICE in BusyBox ash: SYNTAX_OK, `MPJoinSynRx` -> 2, `MPJoinAckRx` -> 2, `MPCurrEstab` -> 0, no-arg mode prints all pairs, and an unknown counter exits 1 with a clear message. Also documents the wpad/ujail control-socket fix (chown network:network /var/run/wpa_supplicant, chmod 770) and warns that it is not persistent across reboot since /var/run is tmpfs. * chore(mptcp): PROGRESS iteration 3 - operator runbook done Runbook + read_mptcp_counter.sh committed as 5c30576. Records that verifying the runbook against the real router caught three defects in the draft (ss -Min empty on OpenWrt, paste missing from BusyBox, ip route get returning local for the router's own address). * feat(mptcp): reconciler keeps bond state in step with interface events Adds the seam between the network monitor and the kernel: Reconciler turns "an uplink came up / went away / changed address" into "re-render and apply the bond". Why this exists: a bond on a mobile box is a moving target. A wireless uplink appears when the STA associates, disappears when it roams, and its address changes when DHCP renews. A script applied once at boot drifts from reality within minutes, and the failure is SILENT -- the bond quietly stops using the interface that came back and nobody notices until throughput is wrong. Two rules keep it safe: * Only uplinks declared in Config matter. A router has many interfaces that flap (guest AP, loopback, container bridges); reconciling on all of them would thrash kernel state for no benefit. * Apply only when the rendered script actually changed. Reconcile runs on every event, and the apply script is delete-then-add, so re-applying an identical script would tear down and rebuild every endpoint -- breaking an in-flight subflow on every unrelated event. An unchanged render is skipped. The side effect is injected as an `Applier func(script string) error` so the decision logic is unit-testable with no router attached, and so the caller chooses how the script is executed. A nil Applier (or nil Config) makes the reconciler inert rather than panicking, since the monitor may fire before configuration is loaded. Methods mirror the upstream_detector event set (HandleInterfaceUp / HandleAddressAdded / HandleInterfaceDown / HandleAddressDeleted) so wiring it to that module's event loop later is a direct call-through with no adapter. Not done here, deliberately: mptcp_bonding is still not imported by main.go or upstream_detector, so nothing calls these methods yet. This is the missing seam, not the wiring. Recorded in PROGRESS.md. Tests: 6 reconciler tests + 8 event/introspection tests, all written RED first (build failed with `undefined: NewReconciler`). Package coverage 91.0% (up from 90.3%), 35 tests, go vet clean, gofmt clean, go build clean. * chore(mptcp): PROGRESS iteration 4 - reconciler built; wiring is not yet possible Records that item 5 was premature: mptcp_bonding is not imported anywhere and not initialised in main.go, so there was no lifecycle to attach an event handler to. Built the missing seam (Reconciler, c8e2d0f) with methods mirroring upstream_detector's event set, and flagged the remaining cross-module wiring as the new next item with its blast radius noted. * feat(mptcp): config loader keeps the package a leaf module Adds LoadConfig([]byte) (*Config, error), which the host module calls with raw JSON from config_manager. Why a loader and not a config_manager import: this package must stay a LEAF. upstream_detector already depends on config_manager, so if mptcp_bonding also depended on config_manager and upstream_detector then imported mptcp_bonding, the edges would close into a cycle. Keeping mptcp_bonding dependency-free means the cross-module integration stays one-directional and this decision can be tested with no running config manager. Three behaviours worth stating: * Absent config is a DISABLED bond, not an error. Every node deployed before this feature has no such section; failing to load would take the module down on upgrade instead of leaving the node exactly as it was. * Unknown keys are REJECTED (DisallowUnknownFields). A typo like `enabld: true` silently leaving the bond off is the class of bug that costs hours in the field, so it is loud. * Validation runs at load, so an unusable bond fails at startup with a clear message rather than silently misrouting a paying customer's traffic. Also adds ConfigJSONExample(), which renders the on-disk shape FROM the type so the documented example cannot drift from the real field names. It deliberately emits no password field: the credential is supplied at runtime and must never be committed or printed. Not done here: main.go still does not load or apply this. This is the package-side of the seam; the host-side wiring (main.go + config_manager schema + upstream_detector event call-through) is the remaining step and is recorded in PROGRESS.md with its blast radius. Tests: 5 loader tests written RED first (build failed with `undefined: LoadConfig`), plus an example-config test. 41 tests total, coverage 91.1%, go vet clean, gofmt clean. * chore(mptcp): PROGRESS iteration 5 - loader done; host wiring split out Records the cycle hazard that forces mptcp_bonding to stay a leaf module (upstream_detector already depends on config_manager), the LoadConfig behaviours (2071e0c), and that the remaining host-side wiring 6a/6b/6c touches the module managing live node connectivity -- flagged rather than executed. * docs(mptcp): two-gateway measurement - StarGate uplink works, test was mis-shaped Operator chose option (a): put StarGate on the router as a second uplink and retry for a real aggregate number. ACHIEVED: the router now has two uplinks with genuinely independent gateways, which is what the previous attempt lacked. lan1 192.168.2.15/24 -> 192.168.2.1 (house wired) phy0-sta0 10.2.1.122/16 -> 10.2.0.1 (StarGate / Starlink) STA built via uci + wifi reload (per the MT7986 rule), associated at -73 dBm, gateway pings at 2.1 ms, and StarGate reaches 8.8.8.8 at 35 ms. MPTCP endpoint registered `signal subflow`. Subflow formation still healthy (MPJoinSynRx delta 2, MPCurrEstab 2). RESULT: throughput unchanged, 925-927 single-path vs 926-928 bonded. But the important finding is that THIS TEST WAS MIS-SHAPED, and that is stated in the document rather than glossed: * the iperf3 server runs ON THE ROUTER, so traffic stops at the router's own socket and NEVER TRAVERSES THE UPLINKS. No uplink topology could show up in that number. * the client link is 1 GbE and measured 925-928 Mbit/s = wire speed, so the local hop is saturated anyway. So the honest conclusion is "aggregation was not tested by this run", not "aggregation failed". The mechanism is proven; the throughput question stays open pending a test with the far end beyond the uplinks and no saturated hop. Refs: docs/MPTCP-MEASUREMENT-2GATEWAY.md * docs(mptcp): per-subflow evidence - 2nd subflow forms on the SAME path Decisive conntrack evidence for the no-gain result: during a live 30s transfer both MPTCP connections carry the SAME 5-tuple addresses (10.63.101.201 -> 10.63.101.1), differing only in source port. sport=51104 41747 pkts 869303545 bytes <- all data sport=51090 8 pkts 725 bytes <- handshake only So the second subflow is created on the same path as the first: healthy handshake, zero capacity added. This is a distinct mechanism from both earlier explanations and explains the flat throughput precisely. Also records that only the 'default' MPTCP scheduler exists on this kernel (roundrobin/redundant refused), so the latency-preferring default cannot be swapped -- and that the default is correct to prefer one path anyway. * docs(mptcp): dual-path measurement - two subflows carry data, no gain from a shared segment Follows the two-gateway attempt. The client was pointed at the router over BOTH of its local paths so the subflows could not collapse onto one 5-tuple: wlp58s0 192.168.2.36 -> router 192.168.2.15 (wifi -> house LAN) enx6c6e0750c33b 10.63.101.201 -> router 192.168.2.15 (direct cable) Obstacle cleared first: `lan1` is classified as WAN on this router (`iifname "lan1" jump input_wan`, policy drop), so inbound TCP to 192.168.2.15 was refused while ICMP worked. A temporary accept rule for the test port was inserted and then removed (verified: handle deleted, ruleset count 0). PROVEN: two subflows on two different client interfaces carried real data at the same time. Mid-transfer conntrack shows both ESTABLISHED with large counts (49823 pkts / 510695705 B and 85187 pkts / 1256176480 B from two distinct client source addresses), and MPJoinSynRx advanced 2 -> 16. NOT PROVEN, and stated as such: a throughput gain. wifi only 1.25 Gbit/s cable only 1.20 Gbit/s aggregated (3 runs) 1.19 / 1.21 / 1.27 Gbit/s Aggregated sits within noise of either single path. Cause: both of the client's paths terminate on the SAME house LAN switch. The subflows are logically distinct but physically share one network, so there is no capacity to aggregate and the scheduler correctly declines to split across a shared bottleneck. The lesson generalises: subflows must be disjoint all the way down, not merely distinct at the socket level -- distinct local addresses are necessary but not sufficient. Refs: docs/MPTCP-MEASUREMENT-DUALPATH.md * chore(mptcp): PROGRESS iteration 6 - StarGate uplink live, two real subflows, no gain Records: StarGate STA built via uci+wifi reload (10.2.1.122/16, gw 10.2.0.1); two subflows on two different client interfaces carried real data simultaneously (distinct source addrs, large byte counts); but no throughput gain because both client paths share the same house LAN switch. Also records the invalid earlier test (server on the router, traffic never reached the uplinks) and that the router was left clean. * feat(config): mptcp_bonding config section, disabled by default 6a + 6b of the host-side wiring. Adds the schema entry and the typed struct so an operator can set a bond through the normal `tollgate config` path. Both of this package's guardrails fired during the work and each caught a real defect, which is why they exist: * TestSchemaHasNoOrphanEntries rejected the schema entry until the Config struct gained a matching json tag -- so the two cannot drift. * TestSchemaDefaultsMatchNewDefaultConfig rejected the schema defaults until NewDefaultConfig carried them -- so schema and code defaults stay in step. Design choices worth stating: * Requi…
Closes #791. Single commit, off current
main(4614ac2). Freeze-compatible: CI/tests/tooling only — the one dependency change is a dev tool (scripts/token-recovery) moving to the versionmainalready pins everywhere else; no shipped surface moves.The drift, and what now makes it impossible
Three incidents, one root cause (#791): carriers enumerated in prose and in a checker scoped to
src/, whilego.modfiles exist repo-wide.scripts/token-recoverystayed at v0.10.0 (predating the fork's #480/#495/#496 counter fixes)--latestleg inrelease-check.sh— a release is not cut on a stale pinThe pieces
packaging/build-inputs.jsongains.gonuts(repo, version, rule comment) — the same manifest-truth idiom asgo.versionandportal.commit.tests/contract/check-gonuts-pin.py— discovers carriers by repo-rootrglob("go.mod")(the test(contract): four drift fences — fragment identity, toolchain ceiling, SSID matcher table, clean-container lane #775 ceiling's enumeration), matches BOTHgo.modrequire shapes, and refuses any disagreement with the manifest, including a go.sum that never recorded the truth version (the half-update shape). Offline, milliseconds.--latestis the network leg: real tag AND newest stable tag — wired ONLY intoscripts/release-check.sh, because failing every open PR the moment the fork tags would be noise; refusing to cut a release is the catch.tests/contract/gonuts-pin_test.sh— 7 planted-drift cases (both require shapes, the stale-carrier shape, the sum-lag shape, a self-consistent pseudo-version accepted offline, missing manifest key, carrierless tree). A fence that cannot fail is decoration.scripts/bump-gonuts.sh <tag>— manifest first, then every discovered carrier viago get+go mod tidy(never hand-edited sums), then the full--latestverdict. A half-update cannot happen.hooks/pre-commit(offline only; triggers ongo.mod/go.sum/build-inputs.json; network stays out of hooks), both workflow twins (offline, beside deps-sync),release-check.sh(offline +--latest).replacedirective" claim is dropped (the tree pins require-only; verified).scripts/token-recoveryraised v0.10.0 → v0.13.0 (the version main already pins). Builds and vets clean against v0.13.0 — the fork's API surface it uses survived three minors.Verification (this branch)
✅ one pin, everywhere … across 5 carrier(s).--latestlive against the real fork: refuses v0.13.0 with "newer stable tag v0.13.2 … this is how the Priority: P2 — NUT-13 residue-collision guard is dead code in gonuts v0.13.0 (Conduition disclosure): CheckCollidingKeysets never invoked; wallet-level fix belongs in gonuts-tollgate, tracked here #705 fix nearly missed a release" — the exact gonuts-tollgate pin drifts: single-source-of-truth pin (manifest + rglob fence + mechanical bump + release-time latest-tag gate) #791 incident, now mechanical.scripts/bump-gonuts.sh v0.13.1moved manifest + all 5 carriers in lockstep (zero identity findings) and then refused to complete because v0.13.2 exists — a maintainer cannot land a deliberate pin choice by accident anymore. Exit non-zero; tree left for the human decision.gonuts-pin_test.sh7/7;check-deps-sync✅ (136 shared);check-toolchain-parity✅;check-import-paths✅;changelog-assemble check✅ (9 fragments);changelog-fragments_test.sh✅;go build/go vetclean inscripts/token-recovery; YAML twins parse;bash -non every touched shell file.src/file is touched.Interplay with the open train
scripts/bump-gonuts.sh <tag>, which also resolves fix(wallet-bump): gonuts-tollgate v0.13.1 — amount==fee swaps refused locally, never POSTed with empty outputs (#752) #780's review finding 1 (pin choice) mechanically.--latestwill correctly refuse a release cut while main pins < v0.13.2 — that refusal is the feature (Priority: P2 — NUT-13 residue-collision guard is dead code in gonuts v0.13.0 (Conduition disclosure): CheckCollidingKeysets never invoked; wallet-level fix belongs in gonuts-tollgate, tracked here #705).Router honesty
No router-visible surface is exercised: the shipped binary's module graph is untouched (the four src carriers stay at v0.13.0), and the fence is offline tooling. The token-recovery change is dev-tooling-only.